From ea76f1d6e2ca48f5efff13d1ad0133f0ca03e363 Mon Sep 17 00:00:00 2001 From: Coffeemakr Date: Tue, 10 Jan 2017 11:41:24 +0100 Subject: [PATCH] Improve DownloadManager and -Service * Fix permission at some places * Fix access problem for downloaded files with external player * Store finished Downloads * Remove binding to DownloadService just to download a file * Javadoc * Code improvements --- app/build.gradle | 1 + app/src/main/AndroidManifest.xml | 10 + .../java/org/schabi/newpipe/MainActivity.java | 4 + .../detail/VideoItemDetailFragment.java | 5 + .../newpipe/download/DownloadActivity.java | 47 ++-- .../newpipe/download/DownloadDialog.java | 66 +---- .../newpipe/download/DownloadListener.java | 62 ---- .../newpipe/download/FileDownloader.java | 2 +- .../youtube/YoutubeStreamExtractor.java | 2 +- .../schabi/newpipe/util/PermissionHelper.java | 68 +++++ .../shandian/giga/get/DownloadDataSource.java | 36 +++ .../us/shandian/giga/get/DownloadManager.java | 40 ++- .../giga/get/DownloadManagerImpl.java | 265 ++++++++++++++---- .../us/shandian/giga/get/DownloadMission.java | 159 +++++++++-- .../shandian/giga/get/DownloadRunnable.java | 13 +- .../giga/get/DownloadRunnableFallback.java | 5 +- .../sqlite/DownloadMissionSQLiteHelper.java | 102 +++++++ .../get/sqlite/SQLiteDownloadDataSource.java | 79 ++++++ .../giga/service/DownloadManagerService.java | 183 ++++++++---- .../giga/ui/adapter/MissionAdapter.java | 69 +++-- .../giga/ui/fragment/MissionsFragment.java | 11 +- app/src/main/res/xml/provider_paths.xml | 4 + .../giga/get/get/DownloadManagerImplTest.java | 156 +++++++++++ 23 files changed, 1066 insertions(+), 323 deletions(-) delete mode 100644 app/src/main/java/org/schabi/newpipe/download/DownloadListener.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/PermissionHelper.java create mode 100644 app/src/main/java/us/shandian/giga/get/DownloadDataSource.java create mode 100644 app/src/main/java/us/shandian/giga/get/sqlite/DownloadMissionSQLiteHelper.java create mode 100644 app/src/main/java/us/shandian/giga/get/sqlite/SQLiteDownloadDataSource.java create mode 100644 app/src/main/res/xml/provider_paths.xml create mode 100644 app/src/test/java/us/shandian/giga/get/get/DownloadManagerImplTest.java diff --git a/app/build.gradle b/app/build.gradle index 266f7fadb..9500c0a32 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -46,5 +46,6 @@ dependencies { compile 'com.google.code.gson:gson:2.4' compile 'com.nononsenseapps:filepicker:3.0.0' testCompile 'junit:junit:4.12' + testCompile 'org.mockito:mockito-core:1.10.19' compile 'ch.acra:acra:4.9.0' } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9c5ea2381..2b44f0b53 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -161,6 +161,16 @@ + + + + \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/MainActivity.java b/app/src/main/java/org/schabi/newpipe/MainActivity.java index bfeb74287..1a6305d28 100644 --- a/app/src/main/java/org/schabi/newpipe/MainActivity.java +++ b/app/src/main/java/org/schabi/newpipe/MainActivity.java @@ -11,6 +11,7 @@ import android.view.MenuInflater; import android.view.MenuItem; import org.schabi.newpipe.settings.SettingsActivity; +import org.schabi.newpipe.util.PermissionHelper; /** * Created by Christian Schabesberger on 02.08.16. @@ -74,6 +75,9 @@ public class MainActivity extends AppCompatActivity { return true; } case R.id.action_show_downloads: { + if(!PermissionHelper.checkStoragePermissions(this)) { + return false; + } Intent intent = new Intent(this, org.schabi.newpipe.download.DownloadActivity.class); startActivity(intent); return true; diff --git a/app/src/main/java/org/schabi/newpipe/detail/VideoItemDetailFragment.java b/app/src/main/java/org/schabi/newpipe/detail/VideoItemDetailFragment.java index 2c6e4b55a..c507a07d3 100644 --- a/app/src/main/java/org/schabi/newpipe/detail/VideoItemDetailFragment.java +++ b/app/src/main/java/org/schabi/newpipe/detail/VideoItemDetailFragment.java @@ -59,6 +59,7 @@ import org.schabi.newpipe.extractor.stream_info.VideoStream; import org.schabi.newpipe.player.BackgroundPlayer; import org.schabi.newpipe.player.PlayVideoActivity; import org.schabi.newpipe.player.ExoPlayerActivity; +import org.schabi.newpipe.util.PermissionHelper; import static android.app.Activity.RESULT_OK; import static org.schabi.newpipe.ReCaptchaActivity.RECAPTCHA_REQUEST; @@ -393,6 +394,10 @@ public class VideoItemDetailFragment extends Fragment { actionBarHandler.setOnDownloadListener(new ActionBarHandler.OnActionListener() { @Override public void onActionSelected(int selectedStreamId) { + if(!PermissionHelper.checkStoragePermissions(getActivity())) { + return; + } + try { Bundle args = new Bundle(); diff --git a/app/src/main/java/org/schabi/newpipe/download/DownloadActivity.java b/app/src/main/java/org/schabi/newpipe/download/DownloadActivity.java index a7e52c486..ab833be0e 100644 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadActivity.java +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadActivity.java @@ -15,6 +15,7 @@ import android.support.v4.app.NavUtils; import android.support.v7.app.ActionBar; import android.support.v7.app.AppCompatActivity; import android.support.v7.widget.Toolbar; +import android.util.Log; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; @@ -30,6 +31,7 @@ import android.widget.Toast; import org.schabi.newpipe.report.ErrorActivity; import org.schabi.newpipe.R; +import org.schabi.newpipe.settings.NewPipeSettings; import org.schabi.newpipe.settings.SettingsActivity; import java.io.File; @@ -53,26 +55,11 @@ public class DownloadActivity extends AppCompatActivity implements AdapterView.O private MissionsFragment mFragment; - private DownloadManager mManager; - private DownloadManagerService.DMBinder mBinder; + private String mPendingUrl; private SharedPreferences mPrefs; - private ServiceConnection mConnection = new ServiceConnection() { - - @Override - public void onServiceConnected(ComponentName p1, IBinder binder) { - mBinder = (DownloadManagerService.DMBinder) binder; - mManager = mBinder.getDownloadManager(); - } - - @Override - public void onServiceDisconnected(ComponentName p1) { - - } - }; - @Override @TargetApi(21) protected void onCreate(Bundle savedInstanceState) { @@ -83,7 +70,6 @@ public class DownloadActivity extends AppCompatActivity implements AdapterView.O Intent i = new Intent(); i.setClass(this, DownloadManagerService.class); startService(i); - bindService(i, mConnection, Context.BIND_AUTO_CREATE); super.onCreate(savedInstanceState); setContentView(R.layout.activity_downloader); @@ -91,7 +77,7 @@ public class DownloadActivity extends AppCompatActivity implements AdapterView.O //noinspection ConstantConditions - // its ok if this failes, we will catch that error later, and send it as report + // its ok if this fails, we will catch that error later, and send it as report ActionBar actionBar = getSupportActionBar(); actionBar.setDisplayHomeAsUpEnabled(true); actionBar.setTitle(R.string.downloads_title); @@ -202,22 +188,24 @@ public class DownloadActivity extends AppCompatActivity implements AdapterView.O @Override public boolean onMenuItemClick(MenuItem item) { if (item.getItemId() == R.id.okay) { + + String location; + if(audioButton.isChecked()) { + location = NewPipeSettings.getAudioDownloadPath(DownloadActivity.this); + } else { + location = NewPipeSettings.getVideoDownloadPath(DownloadActivity.this); + } + String fName = name.getText().toString().trim(); - File f = new File(mManager.getLocation() + "/" + fName); - + File f = new File(location, fName); if (f.exists()) { Toast.makeText(DownloadActivity.this, R.string.msg_exists, Toast.LENGTH_SHORT).show(); } else { - - while (mBinder == null); - - int res = mManager.startMission( - getIntent().getData().toString(), - fName, - audioButton.isChecked(), - threads.getProgress() + 1); - mBinder.onMissionAdded(mManager.getMission(res)); + DownloadManagerService.startMission( + DownloadActivity.this, + getIntent().getData().toString(), location, fName, + audioButton.isChecked(), threads.getProgress() + 1); mFragment.notifyChange(); mPrefs.edit().putInt(THREADS, threads.getProgress() + 1).commit(); @@ -277,4 +265,5 @@ public class DownloadActivity extends AppCompatActivity implements AdapterView.O super.onOptionsItemSelected(item); } } + } diff --git a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java index 2d2159768..22911fc19 100644 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java @@ -26,6 +26,8 @@ import android.widget.TextView; import org.schabi.newpipe.App; import org.schabi.newpipe.R; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.settings.NewPipeSettings; import java.io.File; import java.util.ArrayList; @@ -65,24 +67,6 @@ public class DownloadDialog extends DialogFragment { public static final String AUDIO_URL = "audio_url"; public static final String VIDEO_URL = "video_url"; - private DownloadManager mManager; - private DownloadManagerService.DMBinder mBinder; - - private ServiceConnection mConnection = new ServiceConnection() { - - @Override - public void onServiceConnected(ComponentName p1, IBinder binder) { - mBinder = (DownloadManagerService.DMBinder) binder; - mManager = mBinder.getDownloadManager(); - } - - @Override - public void onServiceDisconnected(ComponentName p1) { - - } - }; - - public DownloadDialog() { } @@ -102,12 +86,6 @@ public class DownloadDialog extends DialogFragment { if(ContextCompat.checkSelfPermission(this.getContext(),Manifest.permission.WRITE_EXTERNAL_STORAGE)!= PackageManager.PERMISSION_GRANTED) ActivityCompat.requestPermissions(getActivity(),new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE},0); - Intent i = new Intent(); - i.setClass(getContext(), DownloadManagerService.class); - getContext().startService(i); - getContext().bindService(i, mConnection, Context.BIND_AUTO_CREATE); - - return inflater.inflate(R.layout.dialog_url, container); } @@ -219,36 +197,22 @@ public class DownloadDialog extends DialogFragment { String fName = name.getText().toString().trim(); - // todo: add timeout? would be bad if the thread gets locked dueto this. - while (mBinder == null); - - if(audioButton.isChecked()){ - int res = mManager.startMission( - arguments.getString(AUDIO_URL), - fName + arguments.getString(FILE_SUFFIX_AUDIO), - audioButton.isChecked(), - threads.getProgress() + 1); - DownloadMission mission = mManager.getMission(res); - mBinder.onMissionAdded(mission); - // add download listener to allow media scan notification - DownloadListener listener = new DownloadListener(getContext(), mission); - mission.addListener(listener); + boolean isAudio = audioButton.isChecked(); + String url, location, filename; + if(isAudio) { + url = arguments.getString(AUDIO_URL); + location = NewPipeSettings.getAudioDownloadPath(getContext()); + filename = fName + arguments.getString(FILE_SUFFIX_AUDIO); + } else { + url = arguments.getString(VIDEO_URL); + location = NewPipeSettings.getVideoDownloadPath(getContext()); + filename = fName + arguments.getString(FILE_SUFFIX_VIDEO); } - if(videoButton.isChecked()){ - int res = mManager.startMission( - arguments.getString(VIDEO_URL), - fName + arguments.getString(FILE_SUFFIX_VIDEO), - audioButton.isChecked(), - threads.getProgress() + 1); - DownloadMission mission = mManager.getMission(res); - mBinder.onMissionAdded(mission); - // add download listener to allow media scan notification - DownloadListener listener = new DownloadListener(getContext(), mission); - mission.addListener(listener); - } + DownloadManagerService.startMission(getContext(), url, location, filename, isAudio, + threads.getProgress() + 1); + getDialog().dismiss(); - } private void download(String url, String title, diff --git a/app/src/main/java/org/schabi/newpipe/download/DownloadListener.java b/app/src/main/java/org/schabi/newpipe/download/DownloadListener.java deleted file mode 100644 index eab30fddd..000000000 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadListener.java +++ /dev/null @@ -1,62 +0,0 @@ -package org.schabi.newpipe.download; - -import android.content.Context; -import android.content.Intent; -import android.net.Uri; - -import us.shandian.giga.get.DownloadMission; -import us.shandian.giga.get.DownloadMission.MissionListener; - -/** - * Created by erwin on 06.11.16. - * - * Copyright (C) Christian Schabesberger 2016 - * DownloadListener.java is part of NewPipe. - * - * NewPipe 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. - * - * NewPipe 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 NewPipe. If not, see . - */ - -class DownloadListener implements MissionListener -{ - DownloadMission mMission; - Context mContext; - - public DownloadListener(Context context, DownloadMission mission) - { - super(); - mMission = mission; - mContext = context; - } - - @Override - public void onProgressUpdate(long done, long total) - { - // do nothing special ... - } - - @Override - public void onFinish() - { - // notify media scanner on downloaded media file ... - mContext.sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, - Uri.parse( "file://" + mMission.location - + "/" + mMission.name))); - } - - @Override - public void onError(int errCode) - { - // do nothing special ... - } -} diff --git a/app/src/main/java/org/schabi/newpipe/download/FileDownloader.java b/app/src/main/java/org/schabi/newpipe/download/FileDownloader.java index 1da8d0182..b097a035a 100644 --- a/app/src/main/java/org/schabi/newpipe/download/FileDownloader.java +++ b/app/src/main/java/org/schabi/newpipe/download/FileDownloader.java @@ -42,7 +42,7 @@ import info.guardianproject.netcipher.NetCipher; */ -// TODO: FOR HEVEN SAKE !!! DO NOT SIMPLY USE ASYNCTASK. MAKE THIS A PROPER SERVICE !!! +// TODO: FOR HEAVEN SAKE !!! DO NOT SIMPLY USE ASYNCTASK. MAKE THIS A PROPER SERVICE !!! public class FileDownloader extends AsyncTask { public static final String TAG = "FileDownloader"; diff --git a/app/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeStreamExtractor.java b/app/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeStreamExtractor.java index 5910a1efb..78db5d36a 100644 --- a/app/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeStreamExtractor.java +++ b/app/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeStreamExtractor.java @@ -88,7 +88,7 @@ public class YoutubeStreamExtractor extends StreamExtractor { // $$el_type$$ will be replaced by the actual el_type (se the declarations below) private static final String GET_VIDEO_INFO_URL = "https://www.youtube.com/get_video_info?video_id=%%video_id%%$$el_type$$&ps=default&eurl=&gl=US&hl=en"; - // eltype is nececeary for the url aboth + // eltype is necessary for the url above private static final String EL_INFO = "el=info"; public enum ItagType { diff --git a/app/src/main/java/org/schabi/newpipe/util/PermissionHelper.java b/app/src/main/java/org/schabi/newpipe/util/PermissionHelper.java new file mode 100644 index 000000000..4c43426c5 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/PermissionHelper.java @@ -0,0 +1,68 @@ +package org.schabi.newpipe.util; + +import android.Manifest; +import android.app.Activity; +import android.content.pm.PackageManager; +import android.os.Build; +import android.support.annotation.RequiresApi; +import android.support.v4.app.ActivityCompat; +import android.support.v4.content.ContextCompat; + +public class PermissionHelper { + public static final int PERMISSION_WRITE_STORAGE = 778; + public static final int PERMISSION_READ_STORAGE = 777; + + + + public static boolean checkStoragePermissions(Activity activity) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + if(!checkReadStoragePermissions(activity)) return false; + } + return checkWriteStoragePermissions(activity); + } + + @RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN) + public static boolean checkReadStoragePermissions(Activity activity) { + if (ContextCompat.checkSelfPermission(activity, Manifest.permission.READ_EXTERNAL_STORAGE) + != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions(activity, + new String[]{ + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.WRITE_EXTERNAL_STORAGE}, + PERMISSION_READ_STORAGE); + + return false; + } + return true; + } + + + public static boolean checkWriteStoragePermissions(Activity activity) { + // Here, thisActivity is the current activity + if (ContextCompat.checkSelfPermission(activity, + Manifest.permission.WRITE_EXTERNAL_STORAGE) + != PackageManager.PERMISSION_GRANTED) { + + // Should we show an explanation? + /*if (ActivityCompat.shouldShowRequestPermissionRationale(activity, + Manifest.permission.WRITE_EXTERNAL_STORAGE)) { + + // Show an explanation to the user *asynchronously* -- don't block + // this thread waiting for the user's response! After the user + // sees the explanation, try again to request the permission. + } else {*/ + + // No explanation needed, we can request the permission. + ActivityCompat.requestPermissions(activity, + new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, + PERMISSION_WRITE_STORAGE); + + // PERMISSION_WRITE_STORAGE is an + // app-defined int constant. The callback method gets the + // result of the request. + /*}*/ + return false; + } + return true; + } +} diff --git a/app/src/main/java/us/shandian/giga/get/DownloadDataSource.java b/app/src/main/java/us/shandian/giga/get/DownloadDataSource.java new file mode 100644 index 000000000..87f550cc4 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/get/DownloadDataSource.java @@ -0,0 +1,36 @@ +package us.shandian.giga.get; + +import java.util.List; + +/** + * Provides access to the storage of {@link DownloadMission}s + */ +public interface DownloadDataSource { + + /** + * Load all missions + * @return a list of download missions + */ + List loadMissions(); + + /** + * Add a downlaod mission to the storage + * @param downloadMission the download mission to add + * @return the identifier of the mission + */ + void addMission(DownloadMission downloadMission); + + /** + * Update a download mission which exists in the storage + * @param downloadMission the download mission to update + * @throws IllegalArgumentException if the mission was not added to storage + */ + void updateMission(DownloadMission downloadMission); + + + /** + * Delete a download mission + * @param downloadMission the mission to delete + */ + void deleteMission(DownloadMission downloadMission); +} \ No newline at end of file diff --git a/app/src/main/java/us/shandian/giga/get/DownloadManager.java b/app/src/main/java/us/shandian/giga/get/DownloadManager.java index 44eb0bb8e..b6579c86d 100644 --- a/app/src/main/java/us/shandian/giga/get/DownloadManager.java +++ b/app/src/main/java/us/shandian/giga/get/DownloadManager.java @@ -3,12 +3,46 @@ package us.shandian.giga.get; public interface DownloadManager { int BLOCK_SIZE = 512 * 1024; - - int startMission(String url, String name, boolean isAudio, int threads); + + /** + * Start a new download mission + * @param url the url to download + * @param location the location + * @param name the name of the file to create + * @param isAudio true if the download is an audio file + * @param threads the number of threads maximal used to download chunks of the file. @return the identifier of the mission. + */ + int startMission(String url, String location, String name, boolean isAudio, int threads); + + /** + * Resume the execution of a download mission. + * @param id the identifier of the mission to resume. + */ void resumeMission(int id); + + /** + * Pause the execution of a download mission. + * @param id the identifier of the mission to pause. + */ void pauseMission(int id); + + /** + * Deletes the mission from the downloaded list but keeps the downloaded file. + * @param id The mission identifier + */ void deleteMission(int id); + + /** + * Get the download mission by its identifier + * @param id the identifier of the download mission + * @return the download mission or null if the mission doesn't exist + */ DownloadMission getMission(int id); + + /** + * Get the number of download missions. + * @return the number of download missions. + */ int getCount(); - String getLocation(); + } diff --git a/app/src/main/java/us/shandian/giga/get/DownloadManagerImpl.java b/app/src/main/java/us/shandian/giga/get/DownloadManagerImpl.java index 498b9a079..3c37ac7d4 100755 --- a/app/src/main/java/us/shandian/giga/get/DownloadManagerImpl.java +++ b/app/src/main/java/us/shandian/giga/get/DownloadManagerImpl.java @@ -1,17 +1,21 @@ package us.shandian.giga.get; -import android.content.Context; +import android.support.annotation.Nullable; import android.util.Log; import com.google.gson.Gson; -import org.schabi.newpipe.settings.NewPipeSettings; - import java.io.File; +import java.io.FilenameFilter; import java.io.RandomAccessFile; import java.net.HttpURLConnection; import java.net.URL; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; import us.shandian.giga.util.Utility; import static org.schabi.newpipe.BuildConfig.DEBUG; @@ -19,33 +23,48 @@ import static org.schabi.newpipe.BuildConfig.DEBUG; public class DownloadManagerImpl implements DownloadManager { private static final String TAG = DownloadManagerImpl.class.getSimpleName(); - - private Context mContext; - private String mLocation; - protected ArrayList mMissions = new ArrayList(); - - public DownloadManagerImpl(Context context, String location) { - mContext = context; - mLocation = location; - loadMissions(); + private final DownloadDataSource mDownloadDataSource; + + private final ArrayList mMissions = new ArrayList(); + + /** + * Create a new instance + * @param searchLocations the directories to search for unfinished downloads + * @param downloadDataSource the data source for finished downloads + */ + public DownloadManagerImpl(Collection searchLocations, DownloadDataSource downloadDataSource) { + mDownloadDataSource = downloadDataSource; + loadMissions(searchLocations); } - + @Override - public int startMission(String url, String name, boolean isAudio, int threads) { - DownloadMission mission = new DownloadMission(); - mission.url = url; - mission.name = name; - if(isAudio) { - mission.location = NewPipeSettings.getAudioDownloadPath(mContext); - } else { - mission.location = NewPipeSettings.getVideoDownloadPath(mContext); + public int startMission(String url, String location, String name, boolean isAudio, int threads) { + DownloadMission existingMission = getMissionByLocation(location, name); + if(existingMission != null) { + // Already downloaded or downloading + if(existingMission.finished) { + // Overwrite mission + deleteMission(mMissions.indexOf(existingMission)); + } else { + // Rename file (?) + try { + name = generateUniqueName(location, name); + }catch (Exception e) { + Log.e(TAG, "Unable to generate unique name", e); + name = System.currentTimeMillis() + name ; + Log.i(TAG, "Using " + name); + } + } } + + DownloadMission mission = new DownloadMission(name, url, location); mission.timestamp = System.currentTimeMillis(); mission.threadCount = threads; - new Initializer(mContext, mission).start(); + mission.addListener(new MissionListener(mission)); + new Initializer(mission).start(); return insertMission(mission); } - + @Override public void resumeMission(int i) { DownloadMission d = getMission(i); @@ -53,7 +72,7 @@ public class DownloadManagerImpl implements DownloadManager d.start(); } } - + @Override public void pauseMission(int i) { DownloadMission d = getMission(i); @@ -61,55 +80,94 @@ public class DownloadManagerImpl implements DownloadManager d.pause(); } } - + @Override public void deleteMission(int i) { - getMission(i).delete(); + DownloadMission mission = getMission(i); + if(mission.finished) { + mDownloadDataSource.deleteMission(mission); + } + mission.delete(); mMissions.remove(i); } - - private void loadMissions() { - File f = new File(mLocation); + + private void loadMissions(Iterable searchLocations) { + mMissions.clear(); + loadFinishedMissions(); + for(String location: searchLocations) { + loadMissions(location); + } + + } + + + /** + * Loads finished missions from the data source + */ + private void loadFinishedMissions() { + List finishedMissions = mDownloadDataSource.loadMissions(); + if(finishedMissions == null) { + finishedMissions = new ArrayList<>(); + } + // Ensure its sorted + Collections.sort(finishedMissions, new Comparator() { + @Override + public int compare(DownloadMission o1, DownloadMission o2) { + return (int) (o1.timestamp - o2.timestamp); + } + }); + mMissions.ensureCapacity(mMissions.size() + finishedMissions.size()); + for(DownloadMission mission: finishedMissions) { + File downloadedFile = mission.getDownloadedFile(); + if(!downloadedFile.isFile()) { + if(DEBUG) { + Log.d(TAG, "downloaded file removed: " + downloadedFile.getAbsolutePath()); + } + mDownloadDataSource.deleteMission(mission); + } else { + mission.length = downloadedFile.length(); + mission.finished = true; + mission.running = false; + mMissions.add(mission); + } + } + } + + private void loadMissions(String location) { + + File f = new File(location); if (f.exists() && f.isDirectory()) { File[] subs = f.listFiles(); - + + if(subs == null) { + Log.e(TAG, "listFiles() returned null"); + return; + } + for (File sub : subs) { - if (sub.isDirectory()) { - continue; - } - - if (sub.getName().endsWith(".giga")) { + if (sub.isFile() && sub.getName().endsWith(".giga")) { String str = Utility.readFromFile(sub.getAbsolutePath()); if (str != null && !str.trim().equals("")) { - + if (DEBUG) { Log.d(TAG, "loading mission " + sub.getName()); Log.d(TAG, str); } - + DownloadMission mis = new Gson().fromJson(str, DownloadMission.class); - + if (mis.finished) { - sub.delete(); + if(!sub.delete()) { + Log.w(TAG, "Unable to delete .giga file: " + sub.getPath()); + } continue; } - + mis.running = false; mis.recovered = true; insertMission(mis); } - } else if (!sub.getName().startsWith(".") && !new File(sub.getPath() + ".giga").exists()) { - // Add a dummy mission for downloaded files - DownloadMission mis = new DownloadMission(); - mis.length = sub.length(); - mis.done = mis.length; - mis.finished = true; - mis.running = false; - mis.name = sub.getName(); - mis.location = mLocation; - mis.timestamp = sub.lastModified(); - insertMission(mis); } } } @@ -144,18 +202,81 @@ public class DownloadManagerImpl implements DownloadManager return i; } - - @Override - public String getLocation() { - return mLocation; + + /** + * Get a mission by its location and name + * @param location the location + * @param name the name + * @return the mission or null if no such mission exists + */ + private @Nullable DownloadMission getMissionByLocation(String location, String name) { + for(DownloadMission mission: mMissions) { + if(location.equals(mission.location) && name.equals(mission.name)) { + return mission; + } + } + return null; } - + + /** + * Splits the filename into name and extension + * + * Dots are ignored if they appear: not at all, at the beginning of the file, + * at the end of the file + * + * @param name the name to split + * @return a string array with a length of 2 containing the name and the extension + */ + private static String[] splitName(String name) { + int dotIndex = name.lastIndexOf('.'); + if(dotIndex <= 0 || (dotIndex == name.length() - 1)) { + return new String[]{name, ""}; + } else { + return new String[]{name.substring(0, dotIndex), name.substring(dotIndex + 1)}; + } + } + + /** + * Generates a unique file name. + * + * e.g. "myname (1).txt" if the name "myname.txt" exists. + * @param location the location (to check for existing files) + * @param name the name of the file + * @return the unique file name + * @throws IllegalArgumentException if the location is not a directory + * @throws SecurityException if the location is not readable + */ + private static String generateUniqueName(String location, String name) { + if(location == null) throw new NullPointerException("location is null"); + if(name == null) throw new NullPointerException("name is null"); + File destination = new File(location); + if(!destination.isDirectory()) { + throw new IllegalArgumentException("location is not a directory: " + location); + } + final String[] nameParts = splitName(name); + String[] existingName = destination.list(new FilenameFilter() { + @Override + public boolean accept(File dir, String name) { + return name.startsWith(nameParts[0]); + } + }); + Arrays.sort(existingName); + String newName; + int downloadIndex = 0; + do { + newName = nameParts[0] + " (" + downloadIndex + ")." + nameParts[1]; + ++downloadIndex; + if(downloadIndex == 1000) { // Probably an error on our side + throw new RuntimeException("Too many existing files"); + } + } while (Arrays.binarySearch(existingName, newName) >= 0); + return newName; + } + private class Initializer extends Thread { - private Context context; private DownloadMission mission; - public Initializer(Context context, DownloadMission mission) { - this.context = context; + public Initializer(DownloadMission mission) { this.mission = mission; } @@ -217,4 +338,30 @@ public class DownloadManagerImpl implements DownloadManager } } } + + /** + * Waits for mission to finish to add it to the {@link #mDownloadDataSource} + */ + private class MissionListener implements DownloadMission.MissionListener { + private final DownloadMission mMission; + + private MissionListener(DownloadMission mission) { + if(mission == null) throw new NullPointerException("mission is null"); + // Could the mission be passed in onFinish()? + mMission = mission; + } + + @Override + public void onProgressUpdate(DownloadMission downloadMission, long done, long total) { + } + + @Override + public void onFinish(DownloadMission downloadMission) { + mDownloadDataSource.addMission(mMission); + } + + @Override + public void onError(DownloadMission downloadMission, int errCode) { + } + } } diff --git a/app/src/main/java/us/shandian/giga/get/DownloadMission.java b/app/src/main/java/us/shandian/giga/get/DownloadMission.java index 1cdef125f..54e84c5ab 100644 --- a/app/src/main/java/us/shandian/giga/get/DownloadMission.java +++ b/app/src/main/java/us/shandian/giga/get/DownloadMission.java @@ -1,6 +1,5 @@ package us.shandian.giga.get; -import android.content.Context; import android.os.Handler; import android.os.Looper; import android.util.Log; @@ -10,39 +9,63 @@ import com.google.gson.Gson; import java.io.File; import java.lang.ref.WeakReference; import java.util.ArrayList; -import java.util.Iterator; import java.util.HashMap; +import java.util.Iterator; import java.util.List; import java.util.Map; import us.shandian.giga.util.Utility; + import static org.schabi.newpipe.BuildConfig.DEBUG; public class DownloadMission { private static final String TAG = DownloadMission.class.getSimpleName(); - + public interface MissionListener { HashMap handlerStore = new HashMap<>(); - void onProgressUpdate(long done, long total); - void onFinish(); - void onError(int errCode); + void onProgressUpdate(DownloadMission downloadMission, long done, long total); + void onFinish(DownloadMission downloadMission); + void onError(DownloadMission downloadMission, int errCode); } public static final int ERROR_SERVER_UNSUPPORTED = 206; public static final int ERROR_UNKNOWN = 233; - - public String name = ""; - public String url = ""; - public String location = ""; + + /** + * The filename + */ + public String name; + + /** + * The url of the file to download + */ + public String url; + + /** + * The directory to store the download + */ + public String location; + + /** + * Number of blocks the size of {@link DownloadManager#BLOCK_SIZE} + */ public long blocks; + + /** + * Number of bytes + */ public long length; + + /** + * Number of bytes downloaded + */ public long done; public int threadCount = 3; public int finishCount; - public List threadPositions = new ArrayList(); - public Map blockState = new HashMap(); + private List threadPositions = new ArrayList(); + public final Map blockState = new HashMap(); public boolean running; public boolean finished; public boolean fallback; @@ -53,23 +76,65 @@ public class DownloadMission private transient ArrayList> mListeners = new ArrayList>(); private transient boolean mWritingToFile; - + + private static final int NO_IDENTIFIER = -1; + private long db_identifier = NO_IDENTIFIER; + + public DownloadMission() { + } + + public DownloadMission(String name, String url, String location) { + if(name == null) throw new NullPointerException("name is null"); + if(name.isEmpty()) throw new IllegalArgumentException("name is empty"); + if(url == null) throw new NullPointerException("url is null"); + if(url.isEmpty()) throw new IllegalArgumentException("url is empty"); + if(location == null) throw new NullPointerException("location is null"); + if(location.isEmpty()) throw new IllegalArgumentException("location is empty"); + this.url = url; + this.name = name; + this.location = location; + } + + + private void checkBlock(long block) { + if(block < 0 || block >= blocks) { + throw new IllegalArgumentException("illegal block identifier"); + } + } + + /** + * Check if a block is reserved + * @param block the block identifier + * @return true if the block is reserved and false if otherwise + */ public boolean isBlockPreserved(long block) { + checkBlock(block); return blockState.containsKey(block) ? blockState.get(block) : false; } public void preserveBlock(long block) { + checkBlock(block); synchronized (blockState) { blockState.put(block, true); } } - - public void setPosition(int id, long position) { - threadPositions.set(id, position); + + /** + * Set the download position of the file + * @param threadId the identifier of the thread + * @param position the download position of the thread + */ + public void setPosition(int threadId, long position) { + threadPositions.set(threadId, position); } - - public long getPosition(int id) { - return threadPositions.get(id); + + /** + * Get the position of a thread + * @param threadId the identifier of the thread + * @return the position for the thread + */ + public long getPosition(int threadId) { + return threadPositions.get(threadId); } public synchronized void notifyProgress(long deltaLen) { @@ -95,13 +160,16 @@ public class DownloadMission MissionListener.handlerStore.get(listener).post(new Runnable() { @Override public void run() { - listener.onProgressUpdate(done, length); + listener.onProgressUpdate(DownloadMission.this, done, length); } }); } } } - + + /** + * Called by a download thread when it finished. + */ public synchronized void notifyFinished() { if (errCode > 0) return; @@ -111,7 +179,10 @@ public class DownloadMission onFinish(); } } - + + /** + * Called when all parts are downloaded + */ private void onFinish() { if (errCode > 0) return; @@ -130,7 +201,7 @@ public class DownloadMission MissionListener.handlerStore.get(listener).post(new Runnable() { @Override public void run() { - listener.onFinish(); + listener.onFinish(DownloadMission.this); } }); } @@ -147,7 +218,7 @@ public class DownloadMission MissionListener.handlerStore.get(listener).post(new Runnable() { @Override public void run() { - listener.onError(errCode); + listener.onError(DownloadMission.this, errCode); } }); } @@ -169,7 +240,10 @@ public class DownloadMission } } } - + + /** + * Start downloading with multiple threads. + */ public void start() { if (!running && !finished) { running = true; @@ -200,12 +274,19 @@ public class DownloadMission // if (err) } } - + + /** + * Removes the file and the meta file + */ public void delete() { deleteThisFromFile(); - new File(location + "/" + name).delete(); + new File(location, name).delete(); } - + + /** + * Write this {@link DownloadMission} to the meta file asynchronously + * if no thread is already running. + */ public void writeThisToFile() { if (!mWritingToFile) { mWritingToFile = true; @@ -218,14 +299,30 @@ public class DownloadMission }.start(); } } - + + /** + * Write this {@link DownloadMission} to the meta file. + */ private void doWriteThisToFile() { synchronized (blockState) { - Utility.writeToFile(location + "/" + name + ".giga", new Gson().toJson(this)); + Utility.writeToFile(getMetaFilename(), new Gson().toJson(this)); } } private void deleteThisFromFile() { - new File(location + "/" + name + ".giga").delete(); + new File(getMetaFilename()).delete(); } + + /** + * Get the path of the meta file + * @return the path to the meta file + */ + private String getMetaFilename() { + return location + "/" + name + ".giga"; + } + + public File getDownloadedFile() { + return new File(location, name); + } + } diff --git a/app/src/main/java/us/shandian/giga/get/DownloadRunnable.java b/app/src/main/java/us/shandian/giga/get/DownloadRunnable.java index 1db03c4be..1df5e716f 100644 --- a/app/src/main/java/us/shandian/giga/get/DownloadRunnable.java +++ b/app/src/main/java/us/shandian/giga/get/DownloadRunnable.java @@ -9,14 +9,19 @@ import java.net.URL; import static org.schabi.newpipe.BuildConfig.DEBUG; +/** + * Runnable to download blocks of a file until the file is completely downloaded, + * an error occurs or the process is stopped. + */ public class DownloadRunnable implements Runnable { private static final String TAG = DownloadRunnable.class.getSimpleName(); - private DownloadMission mMission; - private int mId; + private final DownloadMission mMission; + private final int mId; public DownloadRunnable(DownloadMission mission, int id) { + if(mission == null) throw new NullPointerException("mission is null"); mMission = mission; mId = id; } @@ -86,7 +91,7 @@ public class DownloadRunnable implements Runnable Log.d(TAG, mId + ":Content-Length=" + conn.getContentLength() + " Code:" + conn.getResponseCode()); } - // A server may be ignoring the range requet + // A server may be ignoring the range request if (conn.getResponseCode() != 206) { mMission.errCode = DownloadMission.ERROR_SERVER_UNSUPPORTED; notifyError(DownloadMission.ERROR_SERVER_UNSUPPORTED); @@ -131,7 +136,7 @@ public class DownloadRunnable implements Runnable notifyProgress(-total); if (DEBUG) { - Log.d(TAG, mId + ":position " + position + " retrying"); + Log.d(TAG, mId + ":position " + position + " retrying", e); } } } diff --git a/app/src/main/java/us/shandian/giga/get/DownloadRunnableFallback.java b/app/src/main/java/us/shandian/giga/get/DownloadRunnableFallback.java index 50bdce858..e0a737024 100644 --- a/app/src/main/java/us/shandian/giga/get/DownloadRunnableFallback.java +++ b/app/src/main/java/us/shandian/giga/get/DownloadRunnableFallback.java @@ -8,10 +8,11 @@ import java.net.URL; // Single-threaded fallback mode public class DownloadRunnableFallback implements Runnable { - private DownloadMission mMission; + private final DownloadMission mMission; //private int mId; public DownloadRunnableFallback(DownloadMission mission) { + if(mission == null) throw new NullPointerException("mission is null"); //mId = id; mMission = mission; } @@ -35,7 +36,7 @@ public class DownloadRunnableFallback implements Runnable f.write(buf, 0, len); notifyProgress(len); - if (Thread.currentThread().interrupted()) { + if (Thread.interrupted()) { break; } diff --git a/app/src/main/java/us/shandian/giga/get/sqlite/DownloadMissionSQLiteHelper.java b/app/src/main/java/us/shandian/giga/get/sqlite/DownloadMissionSQLiteHelper.java new file mode 100644 index 000000000..7b86f7dc4 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/get/sqlite/DownloadMissionSQLiteHelper.java @@ -0,0 +1,102 @@ +package us.shandian.giga.get.sqlite; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; + +import us.shandian.giga.get.DownloadMission; + +/** + * SqliteHelper to store {@link us.shandian.giga.get.DownloadMission} + */ +public class DownloadMissionSQLiteHelper extends SQLiteOpenHelper { + + + private final String TAG = "DownloadMissionHelper"; + + // TODO: use NewPipeSQLiteHelper ('s constants) when playlist branch is merged (?) + private static final String DATABASE_NAME = "newpipe.db"; + + private static final int DATABASE_VERSION = 2; + /** + * The table name of download missions + */ + static final String MISSIONS_TABLE_NAME = "download_missions"; + + /** + * The key to the directory location of the mission + */ + static final String KEY_LOCATION = "location"; + /** + * The key to the url of a mission + */ + static final String KEY_URL = "url"; + /** + * The key to the name of a mission + */ + static final String KEY_NAME = "name"; + + /** + * The key to the done. + */ + static final String KEY_DONE = "bytes_downloaded"; + + static final String KEY_TIMESTAMP = "timestamp"; + + /** + * The statement to create the table + */ + private static final String MISSIONS_CREATE_TABLE = + "CREATE TABLE " + MISSIONS_TABLE_NAME + " (" + + KEY_LOCATION + " TEXT NOT NULL, " + + KEY_NAME + " TEXT NOT NULL, " + + KEY_URL + " TEXT NOT NULL, " + + KEY_DONE + " INTEGER NOT NULL, " + + KEY_TIMESTAMP + " INTEGER NOT NULL, " + + " UNIQUE(" + KEY_LOCATION + ", " + KEY_NAME + "));"; + + + DownloadMissionSQLiteHelper(Context context) { + super(context, DATABASE_NAME, null, DATABASE_VERSION); + } + + /** + * Returns all values of the download mission as ContentValues. + * @param downloadMission the download mission + * @return the content values + */ + public static ContentValues getValuesOfMission(DownloadMission downloadMission) { + ContentValues values = new ContentValues(); + values.put(KEY_URL, downloadMission.url); + values.put(KEY_LOCATION, downloadMission.location); + values.put(KEY_NAME, downloadMission.name); + values.put(KEY_DONE, downloadMission.done); + values.put(KEY_TIMESTAMP, downloadMission.timestamp); + return values; + } + + @Override + public void onCreate(SQLiteDatabase db) { + db.execSQL(MISSIONS_CREATE_TABLE); + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + // Currently nothing to do + } + + public static DownloadMission getMissionFromCursor(Cursor cursor) { + if(cursor == null) throw new NullPointerException("cursor is null"); + int pos; + String name = cursor.getString(cursor.getColumnIndexOrThrow(KEY_NAME)); + String location = cursor.getString(cursor.getColumnIndexOrThrow(KEY_LOCATION)); + String url = cursor.getString(cursor.getColumnIndexOrThrow(KEY_URL)); + DownloadMission mission = new DownloadMission(name, url, location); + mission.done = cursor.getLong(cursor.getColumnIndexOrThrow(KEY_DONE)); + mission.timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(KEY_TIMESTAMP)); + mission.finished = true; + return mission; + } +} diff --git a/app/src/main/java/us/shandian/giga/get/sqlite/SQLiteDownloadDataSource.java b/app/src/main/java/us/shandian/giga/get/sqlite/SQLiteDownloadDataSource.java new file mode 100644 index 000000000..556e26a39 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/get/sqlite/SQLiteDownloadDataSource.java @@ -0,0 +1,79 @@ +package us.shandian.giga.get.sqlite; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.util.Log; + +import java.util.ArrayList; +import java.util.List; + +import us.shandian.giga.get.DownloadDataSource; +import us.shandian.giga.get.DownloadMission; + +import static us.shandian.giga.get.sqlite.DownloadMissionSQLiteHelper.KEY_LOCATION; +import static us.shandian.giga.get.sqlite.DownloadMissionSQLiteHelper.KEY_NAME; +import static us.shandian.giga.get.sqlite.DownloadMissionSQLiteHelper.MISSIONS_TABLE_NAME; + + +/** + * Non-thread-safe implementation of {@link DownloadDataSource} + */ +public class SQLiteDownloadDataSource implements DownloadDataSource { + + private static final String TAG = "DownloadDataSourceImpl"; + private final DownloadMissionSQLiteHelper downloadMissionSQLiteHelper; + + public SQLiteDownloadDataSource(Context context) { + downloadMissionSQLiteHelper = new DownloadMissionSQLiteHelper(context); + } + + @Override + public List loadMissions() { + ArrayList result; + SQLiteDatabase database = downloadMissionSQLiteHelper.getReadableDatabase(); + Cursor cursor = database.query(MISSIONS_TABLE_NAME, null, null, + null, null, null, DownloadMissionSQLiteHelper.KEY_TIMESTAMP); + + int count = cursor.getCount(); + if(count == 0) return new ArrayList<>(); + result = new ArrayList<>(count); + while (cursor.moveToNext()) { + result.add(DownloadMissionSQLiteHelper.getMissionFromCursor(cursor)); + } + return result; + } + + @Override + public void addMission(DownloadMission downloadMission) { + if(downloadMission == null) throw new NullPointerException("downloadMission is null"); + SQLiteDatabase database = downloadMissionSQLiteHelper.getWritableDatabase(); + ContentValues values = DownloadMissionSQLiteHelper.getValuesOfMission(downloadMission); + database.insert(MISSIONS_TABLE_NAME, null, values); + } + + @Override + public void updateMission(DownloadMission downloadMission) { + if(downloadMission == null) throw new NullPointerException("downloadMission is null"); + SQLiteDatabase database = downloadMissionSQLiteHelper.getWritableDatabase(); + ContentValues values = DownloadMissionSQLiteHelper.getValuesOfMission(downloadMission); + String whereClause = KEY_LOCATION+ " = ? AND " + + KEY_NAME + " = ?"; + int rowsAffected = database.update(MISSIONS_TABLE_NAME, values, + whereClause, new String[]{downloadMission.location, downloadMission.name}); + if(rowsAffected != 1) { + Log.e(TAG, "Expected 1 row to be affected by update but got " + rowsAffected); + } + } + + @Override + public void deleteMission(DownloadMission downloadMission) { + if(downloadMission == null) throw new NullPointerException("downloadMission is null"); + SQLiteDatabase database = downloadMissionSQLiteHelper.getWritableDatabase(); + database.delete(MISSIONS_TABLE_NAME, + KEY_LOCATION + " = ? AND " + + KEY_NAME + " = ?", + new String[]{downloadMission.location, downloadMission.name}); + } +} diff --git a/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java b/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java index 84ae7e63c..d83ed6dc8 100755 --- a/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java +++ b/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java @@ -4,38 +4,69 @@ import android.Manifest; import android.app.Notification; import android.app.PendingIntent; import android.app.Service; +import android.content.Context; import android.content.Intent; +import android.content.ServiceConnection; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; +import android.net.Uri; import android.os.Binder; import android.os.Handler; import android.os.HandlerThread; import android.os.IBinder; import android.os.Message; import android.support.v4.app.NotificationCompat.Builder; -import android.support.v4.content.ContextCompat; import android.support.v4.content.PermissionChecker; import android.util.Log; import android.widget.Toast; +import org.schabi.newpipe.R; import org.schabi.newpipe.download.DownloadActivity; import org.schabi.newpipe.settings.NewPipeSettings; -import org.schabi.newpipe.R; + +import java.util.ArrayList; + +import us.shandian.giga.get.DownloadDataSource; import us.shandian.giga.get.DownloadManager; import us.shandian.giga.get.DownloadManagerImpl; import us.shandian.giga.get.DownloadMission; +import us.shandian.giga.get.sqlite.SQLiteDownloadDataSource; + import static org.schabi.newpipe.BuildConfig.DEBUG; -public class DownloadManagerService extends Service implements DownloadMission.MissionListener +public class DownloadManagerService extends Service { - + private static final String TAG = DownloadManagerService.class.getSimpleName(); - + + /** + * Message code of update messages stored as {@link Message#what}. + */ + private static final int UPDATE_MESSAGE = 0; + private static final int NOTIFICATION_ID = 1000; + private static final String EXTRA_NAME = "DownloadManagerService.extra.name"; + private static final String EXTRA_LOCATION = "DownloadManagerService.extra.location"; + private static final String EXTRA_IS_AUDIO = "DownloadManagerService.extra.is_audio"; + private static final String EXTRA_THREADS = "DownloadManagerService.extra.threads"; + + private DMBinder mBinder; private DownloadManager mManager; private Notification mNotification; private Handler mHandler; private long mLastTimeStamp = System.currentTimeMillis(); + private DownloadDataSource mDataSource; + + + + private MissionListener missionListener = new MissionListener(); + + + private void notifyMediaScanner(DownloadMission mission) { + Uri uri = Uri.parse("file://" + mission.location + "/" + mission.name); + // notify media scanner on downloaded media file ... + sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, uri)); + } @Override public void onCreate() { @@ -44,14 +75,19 @@ public class DownloadManagerService extends Service implements DownloadMission.M if (DEBUG) { Log.d(TAG, "onCreate"); } - + mBinder = new DMBinder(); + if(mDataSource == null) { + mDataSource = new SQLiteDownloadDataSource(this); + } if (mManager == null) { - String path = NewPipeSettings.getVideoDownloadPath(this); - mManager = new DownloadManagerImpl(this, path); + ArrayList paths = new ArrayList<>(2); + paths.add(NewPipeSettings.getVideoDownloadPath(this)); + paths.add(NewPipeSettings.getAudioDownloadPath(this)); + mManager = new DownloadManagerImpl(paths, mDataSource); if (DEBUG) { Log.d(TAG, "mManager == null"); - Log.d(TAG, "Download directory: " + path); + Log.d(TAG, "Download directory: " + paths); } } @@ -87,28 +123,50 @@ public class DownloadManagerService extends Service implements DownloadMission.M mHandler = new Handler(thread.getLooper()) { @Override public void handleMessage(Message msg) { - if (msg.what == 0) { - int runningCount = 0; - - for (int i = 0; i < mManager.getCount(); i++) { - if (mManager.getMission(i).running) { - runningCount++; + switch (msg.what) { + case UPDATE_MESSAGE: { + int runningCount = 0; + + for (int i = 0; i < mManager.getCount(); i++) { + if (mManager.getMission(i).running) { + runningCount++; + } } + updateState(runningCount); + break; } - - updateState(runningCount); } } }; } + private void startMissionAsync(final String url, final String location, final String name, + final boolean isAudio, final int threads) { + mHandler.post(new Runnable() { + @Override + public void run() { + int missionId = mManager.startMission(url, location, name, isAudio, threads); + mBinder.onMissionAdded(mManager.getMission(missionId)); + } + }); + } + @Override public int onStartCommand(Intent intent, int flags, int startId) { if (DEBUG) { Log.d(TAG, "Starting"); } - + Log.i(TAG, "Got intent: " + intent); + String action = intent.getAction(); + if(action != null && action.equals(Intent.ACTION_RUN)) { + String name = intent.getStringExtra(EXTRA_NAME); + String location = intent.getStringExtra(EXTRA_LOCATION); + int threads = intent.getIntExtra(EXTRA_THREADS, 1); + boolean isAudio = intent.getBooleanExtra(EXTRA_IS_AUDIO, false); + String url = intent.getDataString(); + startMissionAsync(url, location, name, isAudio, threads); + } return START_NOT_STICKY; } @@ -123,65 +181,76 @@ public class DownloadManagerService extends Service implements DownloadMission.M for (int i = 0; i < mManager.getCount(); i++) { mManager.pauseMission(i); } - + stopForeground(true); } @Override public IBinder onBind(Intent intent) { + int permissionCheck; if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.JELLY_BEAN) { - int permissionCheck = PermissionChecker.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE); + permissionCheck = PermissionChecker.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE); if(permissionCheck == PermissionChecker.PERMISSION_DENIED) { Toast.makeText(this, "Permission denied (read)", Toast.LENGTH_SHORT).show(); - return null; - } - permissionCheck = PermissionChecker.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE); - if(permissionCheck == PermissionChecker.PERMISSION_DENIED) { - Toast.makeText(this, "Permission denied (write)", Toast.LENGTH_SHORT).show(); - return null; } } + permissionCheck = PermissionChecker.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE); + if(permissionCheck == PermissionChecker.PERMISSION_DENIED) { + Toast.makeText(this, "Permission denied (write)", Toast.LENGTH_SHORT).show(); + } + return mBinder; } - - @Override - public void onProgressUpdate(long done, long total) { - - long now = System.currentTimeMillis(); - - long delta = now - mLastTimeStamp; - - if (delta > 2000) { - postUpdateMessage(); - mLastTimeStamp = now; - } - } - - @Override - public void onFinish() { - postUpdateMessage(); - } - - @Override - public void onError(int errCode) { - postUpdateMessage(); - } - private void postUpdateMessage() { - mHandler.sendEmptyMessage(0); + mHandler.sendEmptyMessage(UPDATE_MESSAGE); } private void updateState(int runningCount) { if (runningCount == 0) { stopForeground(true); } else { - startForeground(1000, mNotification); + startForeground(NOTIFICATION_ID, mNotification); } } - - + + public static void startMission(Context context, String url, String location, String name, boolean isAudio, int threads) { + Intent intent = new Intent(context, DownloadManagerService.class); + intent.setAction(Intent.ACTION_RUN); + intent.setData(Uri.parse(url)); + intent.putExtra(EXTRA_NAME, name); + intent.putExtra(EXTRA_LOCATION, location); + intent.putExtra(EXTRA_IS_AUDIO, isAudio); + intent.putExtra(EXTRA_THREADS, threads); + context.startService(intent); + } + + + class MissionListener implements DownloadMission.MissionListener { + @Override + public void onProgressUpdate(DownloadMission downloadMission, long done, long total) { + long now = System.currentTimeMillis(); + long delta = now - mLastTimeStamp; + if (delta > 2000) { + postUpdateMessage(); + mLastTimeStamp = now; + } + } + + @Override + public void onFinish(DownloadMission downloadMission) { + postUpdateMessage(); + notifyMediaScanner(downloadMission); + } + + @Override + public void onError(DownloadMission downloadMission, int errCode) { + postUpdateMessage(); + } + } + + // Wrapper of DownloadManager public class DMBinder extends Binder { public DownloadManager getDownloadManager() { @@ -189,15 +258,13 @@ public class DownloadManagerService extends Service implements DownloadMission.M } public void onMissionAdded(DownloadMission mission) { - mission.addListener(DownloadManagerService.this); + mission.addListener(missionListener); postUpdateMessage(); } public void onMissionRemoved(DownloadMission mission) { - mission.removeListener(DownloadManagerService.this); + mission.removeListener(missionListener); postUpdateMessage(); } - } - } diff --git a/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java b/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java index 050b4edf4..094ddf3b3 100644 --- a/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java +++ b/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java @@ -5,6 +5,9 @@ import android.content.Context; import android.content.Intent; import android.net.Uri; import android.os.AsyncTask; +import android.os.Build; +import android.support.v4.content.FileProvider; +import android.util.Log; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuItem; @@ -28,10 +31,14 @@ import us.shandian.giga.service.DownloadManagerService; import us.shandian.giga.ui.common.ProgressDrawable; import us.shandian.giga.util.Utility; +import static android.content.Intent.FLAG_GRANT_PREFIX_URI_PERMISSION; +import static android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION; + public class MissionAdapter extends RecyclerView.Adapter { private static final Map ALGORITHMS = new HashMap<>(); - + private static final String TAG = "MissionAdapter"; + static { ALGORITHMS.put(R.id.md5, "MD5"); ALGORITHMS.put(R.id.sha1, "SHA1"); @@ -212,23 +219,23 @@ public class MissionAdapter extends RecyclerView.Adapter= Build.VERSION_CODES.LOLLIPOP) { + intent.addFlags(FLAG_GRANT_PREFIX_URI_PERMISSION); + } + //mContext.grantUriPermission(packageName, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION); + Log.v(TAG, "Starting intent: " + intent); + mContext.startActivity(intent); + } + + private void viewFileWithFileProvider(File file, String mimetype) { + String ourPackage = mContext.getApplicationContext().getPackageName(); + Uri uri = FileProvider.getUriForFile(mContext, ourPackage + ".provider", file); + Intent intent = new Intent(); + intent.setAction(Intent.ACTION_VIEW); + intent.setDataAndType(uri, mimetype); + intent.addFlags(FLAG_GRANT_READ_URI_PERMISSION); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + intent.addFlags(FLAG_GRANT_PREFIX_URI_PERMISSION); + } + //mContext.grantUriPermission(packageName, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION); + Log.v(TAG, "Starting intent: " + intent); + mContext.startActivity(intent); + } private class ChecksumTask extends AsyncTask { ProgressDialog prog; @@ -316,12 +351,12 @@ public class MissionAdapter extends RecyclerView.Adapter + + + \ No newline at end of file diff --git a/app/src/test/java/us/shandian/giga/get/get/DownloadManagerImplTest.java b/app/src/test/java/us/shandian/giga/get/get/DownloadManagerImplTest.java new file mode 100644 index 000000000..8cef98cbe --- /dev/null +++ b/app/src/test/java/us/shandian/giga/get/get/DownloadManagerImplTest.java @@ -0,0 +1,156 @@ +package us.shandian.giga.get.get; + +import org.junit.Ignore; +import org.junit.Test; + +import java.io.File; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.util.ArrayList; + +import us.shandian.giga.get.DownloadDataSource; +import us.shandian.giga.get.DownloadManagerImpl; +import us.shandian.giga.get.DownloadMission; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Test for {@link DownloadManagerImpl} + * + * TODO: test loading from .giga files, startMission and improve tests + */ +public class DownloadManagerImplTest { + + private DownloadManagerImpl downloadManager; + private DownloadDataSource dowloadDataSource; + private ArrayList missions; + + @org.junit.Before + public void setUp() throws Exception { + dowloadDataSource = mock(DownloadDataSource.class); + missions = new ArrayList<>(); + for(int i = 0; i < 50; ++i){ + missions.add(generateFinishedDownloadMission()); + } + when(dowloadDataSource.loadMissions()).thenReturn(new ArrayList<>(missions)); + downloadManager = new DownloadManagerImpl(new ArrayList(), dowloadDataSource); + } + + @Test(expected = NullPointerException.class) + public void testConstructorWithNullAsDownloadDataSource() { + new DownloadManagerImpl(new ArrayList(), null); + } + + + private static DownloadMission generateFinishedDownloadMission() throws IOException { + File file = File.createTempFile("newpipetest", ".mp4"); + file.deleteOnExit(); + RandomAccessFile randomAccessFile = new RandomAccessFile(file, "rw"); + randomAccessFile.setLength(1000); + randomAccessFile.close(); + DownloadMission downloadMission = new DownloadMission(file.getName(), + "http://google.com/?q=how+to+google", file.getParent()); + downloadMission.blocks = 1000; + downloadMission.done = 1000; + downloadMission.finished = true; + return spy(downloadMission); + } + + private static void assertMissionEquals(String message, DownloadMission expected, DownloadMission actual) { + if(expected == actual) return; + assertEquals(message + ": Name", expected.name, actual.name); + assertEquals(message + ": Location", expected.location, actual.location); + assertEquals(message + ": Url", expected.url, actual.url); + } + + @Test + public void testThatMissionsAreLoaded() throws IOException { + ArrayList missions = new ArrayList<>(); + long millis = System.currentTimeMillis(); + for(int i = 0; i < 50; ++i){ + DownloadMission mission = generateFinishedDownloadMission(); + mission.timestamp = millis - i; // reverse order by timestamp + missions.add(mission); + } + + dowloadDataSource = mock(DownloadDataSource.class); + when(dowloadDataSource.loadMissions()).thenReturn(new ArrayList<>(missions)); + downloadManager = new DownloadManagerImpl(new ArrayList(), dowloadDataSource); + verify(dowloadDataSource, times(1)).loadMissions(); + + assertEquals(50, downloadManager.getCount()); + + for(int i = 0; i < 50; ++i) { + assertMissionEquals("mission " + i, missions.get(50 - 1 - i), downloadManager.getMission(i)); + } + } + + @Ignore + @Test + public void startMission() throws Exception { + DownloadMission mission = missions.get(0); + mission = spy(mission); + missions.set(0, mission); + String url = "https://github.com/favicon.ico"; + // create a temp file and delete it so we have a temp directory + File tempFile = File.createTempFile("favicon",".ico"); + String name = tempFile.getName(); + String location = tempFile.getParent(); + assertTrue(tempFile.delete()); + int id = downloadManager.startMission(url, location, name, true, 10); + } + + @Test + public void resumeMission() throws Exception { + DownloadMission mission = missions.get(0); + mission.running = true; + verify(mission, never()).start(); + downloadManager.resumeMission(0); + verify(mission, never()).start(); + mission.running = false; + downloadManager.resumeMission(0); + verify(mission, times(1)).start(); + } + + @Test + public void pauseMission() throws Exception { + DownloadMission mission = missions.get(0); + mission.running = false; + downloadManager.pauseMission(0); + verify(mission, never()).pause(); + mission.running = true; + downloadManager.pauseMission(0); + verify(mission, times(1)).pause(); + } + + @Test + public void deleteMission() throws Exception { + DownloadMission mission = missions.get(0); + assertEquals(mission, downloadManager.getMission(0)); + downloadManager.deleteMission(0); + verify(mission, times(1)).delete(); + assertNotEquals(mission, downloadManager.getMission(0)); + assertEquals(49, downloadManager.getCount()); + } + + @Test(expected = RuntimeException.class) + public void getMissionWithNegativeIndex() throws Exception { + downloadManager.getMission(-1); + } + + @Test + public void getMission() throws Exception { + assertSame(missions.get(0), downloadManager.getMission(0)); + assertSame(missions.get(1), downloadManager.getMission(1)); + } + +} \ No newline at end of file