diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index eb15cddce..6eb9f514f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -22,7 +22,6 @@ android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:logo="@mipmap/ic_launcher" - android:requestLegacyExternalStorage="true" android:theme="@style/OpeningTheme" android:resizeableActivity="true" tools:ignore="AllowBackup"> diff --git a/app/src/main/java/org/schabi/newpipe/App.java b/app/src/main/java/org/schabi/newpipe/App.java index af118387c..784be8d0b 100644 --- a/app/src/main/java/org/schabi/newpipe/App.java +++ b/app/src/main/java/org/schabi/newpipe/App.java @@ -27,7 +27,7 @@ import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.downloader.Downloader; import org.schabi.newpipe.ktx.ExceptionUtils; -import org.schabi.newpipe.settings.SettingsActivity; +import org.schabi.newpipe.settings.NewPipeSettings; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.ServiceHelper; import org.schabi.newpipe.util.StateSaver; @@ -91,7 +91,7 @@ public class App extends MultiDexApplication { app = this; // Initialize settings first because others inits can use its values - SettingsActivity.initSettings(this); + NewPipeSettings.initSettings(this); NewPipe.init(getDownloader(), Localization.getPreferredLocalization(this), 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 e7ae8d879..c055f8f23 100644 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java @@ -49,6 +49,8 @@ import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.SubtitlesStream; import org.schabi.newpipe.extractor.stream.VideoStream; import org.schabi.newpipe.settings.NewPipeSettings; +import org.schabi.newpipe.streams.io.StoredDirectoryHelper; +import org.schabi.newpipe.streams.io.StoredFileHelper; import org.schabi.newpipe.util.FilePickerActivityHelper; import org.schabi.newpipe.util.FilenameUtils; import org.schabi.newpipe.util.ListHelper; @@ -68,8 +70,6 @@ import icepick.Icepick; import icepick.State; import io.reactivex.rxjava3.disposables.CompositeDisposable; import us.shandian.giga.get.MissionRecoveryInfo; -import us.shandian.giga.io.StoredDirectoryHelper; -import us.shandian.giga.io.StoredFileHelper; import us.shandian.giga.postprocessing.Postprocessing; import us.shandian.giga.service.DownloadManager; import us.shandian.giga.service.DownloadManagerService; @@ -83,6 +83,8 @@ public class DownloadDialog extends DialogFragment private static final String TAG = "DialogFragment"; private static final boolean DEBUG = MainActivity.DEBUG; private static final int REQUEST_DOWNLOAD_SAVE_AS = 0x1230; + private static final int REQUEST_DOWNLOAD_PICK_VIDEO_FOLDER = 0x789E; + private static final int REQUEST_DOWNLOAD_PICK_AUDIO_FOLDER = 0x789F; @State StreamInfo currentInfo; @@ -116,6 +118,10 @@ public class DownloadDialog extends DialogFragment private SharedPreferences prefs; + // Variables for file name and MIME type when picking new folder because it's not set yet + private String filenameTmp; + private String mimeTmp; + public static DownloadDialog newInstance(final StreamInfo info) { final DownloadDialog dialog = new DownloadDialog(); dialog.setInfo(info); @@ -153,10 +159,6 @@ public class DownloadDialog extends DialogFragment setVideoStreams(new StreamSizeWrapper<>(videoStreams, getContext())); } - /*////////////////////////////////////////////////////////////////////////// - // LifeCycle - //////////////////////////////////////////////////////////////////////////*/ - public void setVideoStreams(final StreamSizeWrapper wvs) { this.wrappedVideoStreams = wvs; } @@ -374,12 +376,16 @@ public class DownloadDialog extends DialogFragment public void onActivityResult(final int requestCode, final int resultCode, final Intent data) { super.onActivityResult(requestCode, resultCode, data); - if (requestCode == REQUEST_DOWNLOAD_SAVE_AS && resultCode == Activity.RESULT_OK) { - if (data.getData() == null) { - showFailedDialog(R.string.general_error); - return; - } + if (resultCode != Activity.RESULT_OK) { + return; + } + if (data.getData() == null) { + showFailedDialog(R.string.general_error); + return; + } + + if (requestCode == REQUEST_DOWNLOAD_SAVE_AS) { if (FilePickerActivityHelper.isOwnFileUri(context, data.getData())) { final File file = Utils.getFileForUri(data.getData()); checkSelectedDownload(null, Uri.fromFile(file), file.getName(), @@ -396,6 +402,37 @@ public class DownloadDialog extends DialogFragment // check if the selected file was previously used checkSelectedDownload(null, data.getData(), docFile.getName(), docFile.getType()); + } else if (requestCode == REQUEST_DOWNLOAD_PICK_AUDIO_FOLDER + || requestCode == REQUEST_DOWNLOAD_PICK_VIDEO_FOLDER) { + Uri uri = data.getData(); + if (FilePickerActivityHelper.isOwnFileUri(context, uri)) { + uri = Uri.fromFile(Utils.getFileForUri(uri)); + } else { + context.grantUriPermission(context.getPackageName(), uri, + StoredDirectoryHelper.PERMISSION_FLAGS); + } + + final String key; + final String tag; + if (requestCode == REQUEST_DOWNLOAD_PICK_AUDIO_FOLDER) { + key = getString(R.string.download_path_audio_key); + tag = DownloadManager.TAG_AUDIO; + } else { + key = getString(R.string.download_path_video_key); + tag = DownloadManager.TAG_VIDEO; + } + + PreferenceManager.getDefaultSharedPreferences(context).edit() + .putString(key, uri.toString()).apply(); + + try { + final StoredDirectoryHelper mainStorage + = new StoredDirectoryHelper(context, uri, tag); + checkSelectedDownload(mainStorage, mainStorage.findFile(filenameTmp), + filenameTmp, mimeTmp); + } catch (final IOException e) { + showFailedDialog(R.string.general_error); + } } } @@ -603,84 +640,92 @@ public class DownloadDialog extends DialogFragment private void prepareSelectedDownload() { final StoredDirectoryHelper mainStorage; final MediaFormat format; - final String mime; final String selectedMediaType; // first, build the filename and get the output folder (if possible) // later, run a very very very large file checking logic - String filename = getNameEditText().concat("."); + filenameTmp = getNameEditText().concat("."); switch (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()) { case R.id.audio_button: selectedMediaType = getString(R.string.last_download_type_audio_key); mainStorage = mainStorageAudio; format = audioStreamsAdapter.getItem(selectedAudioIndex).getFormat(); - switch (format) { - case WEBMA_OPUS: - mime = "audio/ogg"; - filename += "opus"; - break; - default: - mime = format.mimeType; - filename += format.suffix; - break; + if (format == MediaFormat.WEBMA_OPUS) { + mimeTmp = "audio/ogg"; + filenameTmp += "opus"; + } else { + mimeTmp = format.mimeType; + filenameTmp += format.suffix; } break; case R.id.video_button: selectedMediaType = getString(R.string.last_download_type_video_key); mainStorage = mainStorageVideo; format = videoStreamsAdapter.getItem(selectedVideoIndex).getFormat(); - mime = format.mimeType; - filename += format.suffix; + mimeTmp = format.mimeType; + filenameTmp += format.suffix; break; case R.id.subtitle_button: selectedMediaType = getString(R.string.last_download_type_subtitle_key); mainStorage = mainStorageVideo; // subtitle & video files go together format = subtitleStreamsAdapter.getItem(selectedSubtitleIndex).getFormat(); - mime = format.mimeType; - filename += (format == MediaFormat.TTML ? MediaFormat.SRT : format).suffix; + mimeTmp = format.mimeType; + filenameTmp += (format == MediaFormat.TTML ? MediaFormat.SRT : format).suffix; break; default: throw new RuntimeException("No stream selected"); } - if (mainStorage == null || askForSavePath) { - // This part is called if with SAF preferred: - // * older android version running - // * save path not defined (via download settings) - // * the user checked the "ask where to download" option + if (!askForSavePath + && (mainStorage == null + || mainStorage.isDirect() == NewPipeSettings.useStorageAccessFramework(context) + || mainStorage.isInvalidSafStorage())) { + // Pick new download folder if one of: + // - Download folder is not set + // - Download folder uses SAF while SAF is disabled + // - Download folder doesn't use SAF while SAF is enabled + // - Download folder uses SAF but the user manually revoked access to it + Toast.makeText(context, getString(R.string.no_dir_yet), + Toast.LENGTH_LONG).show(); - if (!askForSavePath) { - Toast.makeText(context, getString(R.string.no_available_dir), - Toast.LENGTH_LONG).show(); - } - - if (NewPipeSettings.useStorageAccessFramework(context)) { - StoredFileHelper.requestSafWithFileCreation(this, REQUEST_DOWNLOAD_SAVE_AS, - filename, mime); + if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() == R.id.audio_button) { + startActivityForResult(StoredDirectoryHelper.getPicker(context), + REQUEST_DOWNLOAD_PICK_AUDIO_FOLDER); } else { - File initialSavePath; - if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() == R.id.audio_button) { - initialSavePath = NewPipeSettings.getDir(Environment.DIRECTORY_MUSIC); - } else { - initialSavePath = NewPipeSettings.getDir(Environment.DIRECTORY_MOVIES); - } - - initialSavePath = new File(initialSavePath, filename); - startActivityForResult(FilePickerActivityHelper.chooseFileToSave(context, - initialSavePath.getAbsolutePath()), REQUEST_DOWNLOAD_SAVE_AS); + startActivityForResult(StoredDirectoryHelper.getPicker(context), + REQUEST_DOWNLOAD_PICK_VIDEO_FOLDER); } return; } + if (askForSavePath) { + final Uri initialPath; + if (NewPipeSettings.useStorageAccessFramework(context)) { + initialPath = null; + } else { + final File initialSavePath; + if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() == R.id.audio_button) { + initialSavePath = NewPipeSettings.getDir(Environment.DIRECTORY_MUSIC); + } else { + initialSavePath = NewPipeSettings.getDir(Environment.DIRECTORY_MOVIES); + } + initialPath = Uri.parse(initialSavePath.getAbsolutePath()); + } + + startActivityForResult(StoredFileHelper.getNewPicker(context, + filenameTmp, mimeTmp, initialPath), REQUEST_DOWNLOAD_SAVE_AS); + + return; + } + // check for existing file with the same name - checkSelectedDownload(mainStorage, mainStorage.findFile(filename), filename, mime); + checkSelectedDownload(mainStorage, mainStorage.findFile(filenameTmp), filenameTmp, mimeTmp); // remember the last media type downloaded by the user - prefs.edit() - .putString(getString(R.string.last_used_download_type), selectedMediaType) + prefs.edit().putString(getString(R.string.last_used_download_type), selectedMediaType) .apply(); } @@ -708,15 +753,14 @@ public class DownloadDialog extends DialogFragment return; } - // check if is our file + // get state of potential mission referring to the same file final MissionState state = downloadManager.checkForExistingMission(storage); - @StringRes - final int msgBtn; - @StringRes - final int msgBody; + @StringRes final int msgBtn; + @StringRes final int msgBody; + // this switch checks if there is already a mission referring to the same file switch (state) { - case Finished: + case Finished: // there is already a finished mission msgBtn = R.string.overwrite; msgBody = R.string.overwrite_finished_warning; break; @@ -728,7 +772,7 @@ public class DownloadDialog extends DialogFragment msgBtn = R.string.generate_unique_name; msgBody = R.string.download_already_running; break; - case None: + case None: // there is no mission referring to the same file if (mainStorage == null) { // This part is called if: // * using SAF on older android version @@ -763,7 +807,7 @@ public class DownloadDialog extends DialogFragment msgBody = R.string.overwrite_unrelated_warning; break; default: - return; + return; // unreachable } final AlertDialog.Builder askDialog = new AlertDialog.Builder(context) diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt index 8a235fa8a..095c8dbc7 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt @@ -8,7 +8,6 @@ import android.content.Intent import android.content.IntentFilter import android.content.res.Configuration import android.os.Bundle -import android.os.Environment import android.os.Parcelable import android.view.LayoutInflater import android.view.Menu @@ -21,7 +20,6 @@ import androidx.lifecycle.ViewModelProvider import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.preference.PreferenceManager import androidx.recyclerview.widget.GridLayoutManager -import com.nononsenseapps.filepicker.Utils import com.xwray.groupie.Group import com.xwray.groupie.GroupAdapter import com.xwray.groupie.Item @@ -52,17 +50,15 @@ import org.schabi.newpipe.local.subscription.item.HeaderWithMenuItem import org.schabi.newpipe.local.subscription.item.HeaderWithMenuItem.Companion.PAYLOAD_UPDATE_VISIBILITY_MENU_ITEM import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService.EXPORT_COMPLETE_ACTION -import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService.KEY_FILE_PATH import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.IMPORT_COMPLETE_ACTION import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_MODE import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.PREVIOUS_EXPORT_MODE -import org.schabi.newpipe.util.FilePickerActivityHelper +import org.schabi.newpipe.streams.io.StoredFileHelper import org.schabi.newpipe.util.NavigationHelper import org.schabi.newpipe.util.OnClickGesture import org.schabi.newpipe.util.ShareUtils -import java.io.File import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -188,15 +184,17 @@ class SubscriptionFragment : BaseStateFragment() { } private fun onImportPreviousSelected() { - startActivityForResult(FilePickerActivityHelper.chooseSingleFile(activity), REQUEST_IMPORT_CODE) + startActivityForResult(StoredFileHelper.getPicker(activity), REQUEST_IMPORT_CODE) } private fun onExportSelected() { val date = SimpleDateFormat("yyyyMMddHHmm", Locale.ENGLISH).format(Date()) val exportName = "newpipe_subscriptions_$date.json" - val exportFile = File(Environment.getExternalStorageDirectory(), exportName) - startActivityForResult(FilePickerActivityHelper.chooseFileToSave(activity, exportFile.absolutePath), REQUEST_EXPORT_CODE) + startActivityForResult( + StoredFileHelper.getNewPicker(activity, exportName, "application/json", null), + REQUEST_EXPORT_CODE + ) } private fun openReorderDialog() { @@ -207,23 +205,16 @@ class SubscriptionFragment : BaseStateFragment() { super.onActivityResult(requestCode, resultCode, data) if (data != null && data.data != null && resultCode == Activity.RESULT_OK) { if (requestCode == REQUEST_EXPORT_CODE) { - val exportFile = Utils.getFileForUri(data.data!!) - val parentFile = exportFile.parentFile!! - if (!parentFile.canWrite() || !parentFile.canRead()) { - Toast.makeText(activity, R.string.invalid_directory, Toast.LENGTH_SHORT).show() - } else { - activity.startService( - Intent(activity, SubscriptionsExportService::class.java) - .putExtra(KEY_FILE_PATH, exportFile.absolutePath) - ) - } + activity.startService( + Intent(activity, SubscriptionsExportService::class.java) + .putExtra(SubscriptionsExportService.KEY_FILE_PATH, data.data) + ) } else if (requestCode == REQUEST_IMPORT_CODE) { - val path = Utils.getFileForUri(data.data!!).absolutePath ImportConfirmationDialog.show( this, Intent(activity, SubscriptionsImportService::class.java) .putExtra(KEY_MODE, PREVIOUS_EXPORT_MODE) - .putExtra(KEY_VALUE, path) + .putExtra(KEY_VALUE, data.data) ) } } @@ -295,7 +286,8 @@ class SubscriptionFragment : BaseStateFragment() { private fun showLongTapDialog(selectedItem: ChannelInfoItem) { val commands = arrayOf( - getString(R.string.share), getString(R.string.open_in_browser), + getString(R.string.share), + getString(R.string.open_in_browser), getString(R.string.unsubscribe) ) diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionsImportFragment.java b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionsImportFragment.java index f0675da1b..27786bd44 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionsImportFragment.java +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionsImportFragment.java @@ -18,8 +18,6 @@ import androidx.annotation.StringRes; import androidx.appcompat.app.ActionBar; import androidx.core.text.util.LinkifyCompat; -import com.nononsenseapps.filepicker.Utils; - import org.schabi.newpipe.BaseFragment; import org.schabi.newpipe.R; import org.schabi.newpipe.error.ErrorActivity; @@ -30,13 +28,13 @@ import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor; import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService; import org.schabi.newpipe.util.Constants; -import org.schabi.newpipe.util.FilePickerActivityHelper; import org.schabi.newpipe.util.ServiceHelper; import java.util.Collections; import java.util.List; import icepick.State; +import org.schabi.newpipe.streams.io.StoredFileHelper; import static org.schabi.newpipe.extractor.subscription.SubscriptionExtractor.ContentSource.CHANNEL_URL; import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.CHANNEL_URL_MODE; @@ -175,8 +173,7 @@ public class SubscriptionsImportFragment extends BaseFragment { } public void onImportFile() { - startActivityForResult(FilePickerActivityHelper.chooseSingleFile(activity), - REQUEST_IMPORT_FILE_CODE); + startActivityForResult(StoredFileHelper.getPicker(activity), REQUEST_IMPORT_FILE_CODE); } @Override @@ -188,10 +185,10 @@ public class SubscriptionsImportFragment extends BaseFragment { if (resultCode == Activity.RESULT_OK && requestCode == REQUEST_IMPORT_FILE_CODE && data.getData() != null) { - final String path = Utils.getFileForUri(data.getData()).getAbsolutePath(); ImportConfirmationDialog.show(this, new Intent(activity, SubscriptionsImportService.class) - .putExtra(KEY_MODE, INPUT_STREAM_MODE).putExtra(KEY_VALUE, path) + .putExtra(KEY_MODE, INPUT_STREAM_MODE) + .putExtra(KEY_VALUE, data.getData()) .putExtra(Constants.KEY_SERVICE_ID, currentServiceId)); } } diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsExportService.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsExportService.java index 5dfb1bfe5..063103597 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsExportService.java +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsExportService.java @@ -20,7 +20,7 @@ package org.schabi.newpipe.local.subscription.services; import android.content.Intent; -import android.text.TextUtils; +import android.net.Uri; import android.util.Log; import androidx.localbroadcastmanager.content.LocalBroadcastManager; @@ -31,10 +31,11 @@ import org.schabi.newpipe.App; import org.schabi.newpipe.R; import org.schabi.newpipe.database.subscription.SubscriptionEntity; import org.schabi.newpipe.extractor.subscription.SubscriptionItem; +import org.schabi.newpipe.streams.io.SharpOutputStream; +import org.schabi.newpipe.streams.io.StoredFileHelper; -import java.io.File; -import java.io.FileNotFoundException; -import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; import java.util.ArrayList; import java.util.List; @@ -55,8 +56,8 @@ public class SubscriptionsExportService extends BaseImportExportService { + ".services.SubscriptionsExportService.EXPORT_COMPLETE"; private Subscription subscription; - private File outFile; - private FileOutputStream outputStream; + private StoredFileHelper outFile; + private OutputStream outputStream; @Override public int onStartCommand(final Intent intent, final int flags, final int startId) { @@ -64,18 +65,18 @@ public class SubscriptionsExportService extends BaseImportExportService { return START_NOT_STICKY; } - final String path = intent.getStringExtra(KEY_FILE_PATH); - if (TextUtils.isEmpty(path)) { + final Uri path = intent.getParcelableExtra(KEY_FILE_PATH); + if (path == null) { stopAndReportError(new IllegalStateException( - "Exporting to a file, but the path is empty or null"), + "Exporting to a file, but the path is null"), "Exporting subscriptions"); return START_NOT_STICKY; } try { - outFile = new File(path); - outputStream = new FileOutputStream(outFile); - } catch (final FileNotFoundException e) { + outFile = new StoredFileHelper(this, path, "application/json"); + outputStream = new SharpOutputStream(outFile.getStream()); + } catch (final IOException e) { handleError(e); return START_NOT_STICKY; } @@ -122,8 +123,8 @@ public class SubscriptionsExportService extends BaseImportExportService { .subscribe(getSubscriber()); } - private Subscriber getSubscriber() { - return new Subscriber() { + private Subscriber getSubscriber() { + return new Subscriber() { @Override public void onSubscribe(final Subscription s) { subscription = s; @@ -131,7 +132,7 @@ public class SubscriptionsExportService extends BaseImportExportService { } @Override - public void onNext(final File file) { + public void onNext(final StoredFileHelper file) { if (DEBUG) { Log.d(TAG, "startExport() success: file = " + file); } @@ -153,7 +154,7 @@ public class SubscriptionsExportService extends BaseImportExportService { }; } - private Function, File> exportToFile() { + private Function, StoredFileHelper> exportToFile() { return subscriptionItems -> { ImportExportJsonHelper.writeTo(subscriptionItems, outputStream, eventListener); return outFile; diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java index af94934b2..a843ad77c 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java @@ -20,6 +20,7 @@ package org.schabi.newpipe.local.subscription.services; import android.content.Intent; +import android.net.Uri; import android.text.TextUtils; import android.util.Log; @@ -36,12 +37,11 @@ import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.extractor.subscription.SubscriptionItem; import org.schabi.newpipe.ktx.ExceptionUtils; +import org.schabi.newpipe.streams.io.SharpInputStream; +import org.schabi.newpipe.streams.io.StoredFileHelper; import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.util.ExtractorHelper; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; @@ -55,6 +55,7 @@ import io.reactivex.rxjava3.functions.Function; import io.reactivex.rxjava3.schedulers.Schedulers; import static org.schabi.newpipe.MainActivity.DEBUG; +import static org.schabi.newpipe.streams.io.StoredFileHelper.DEFAULT_MIME; public class SubscriptionsImportService extends BaseImportExportService { public static final int CHANNEL_URL_MODE = 0; @@ -101,17 +102,18 @@ public class SubscriptionsImportService extends BaseImportExportService { if (currentMode == CHANNEL_URL_MODE) { channelUrl = intent.getStringExtra(KEY_VALUE); } else { - final String filePath = intent.getStringExtra(KEY_VALUE); - if (TextUtils.isEmpty(filePath)) { + final Uri uri = intent.getParcelableExtra(KEY_VALUE); + if (uri == null) { stopAndReportError(new IllegalStateException( - "Importing from input stream, but file path is empty or null"), + "Importing from input stream, but file path is null"), "Importing subscriptions"); return START_NOT_STICKY; } try { - inputStream = new FileInputStream(new File(filePath)); - } catch (final FileNotFoundException e) { + inputStream = new SharpInputStream( + new StoredFileHelper(this, uri, DEFAULT_MIME).getStream()); + } catch (final IOException e) { handleError(e); return START_NOT_STICKY; } diff --git a/app/src/main/java/org/schabi/newpipe/settings/BasePreferenceFragment.java b/app/src/main/java/org/schabi/newpipe/settings/BasePreferenceFragment.java index b64543d27..8b2bd9c9a 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/BasePreferenceFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/BasePreferenceFragment.java @@ -6,12 +6,16 @@ import android.view.View; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.preference.Preference; import androidx.preference.PreferenceFragmentCompat; import androidx.preference.PreferenceManager; import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.util.ThemeHelper; +import java.util.Objects; + public abstract class BasePreferenceFragment extends PreferenceFragmentCompat { protected final String TAG = getClass().getSimpleName() + "@" + Integer.toHexString(hashCode()); protected final boolean DEBUG = MainActivity.DEBUG; @@ -37,4 +41,11 @@ public abstract class BasePreferenceFragment extends PreferenceFragmentCompat { super.onResume(); ThemeHelper.setTitleToAppCompatActivity(getActivity(), getPreferenceScreen().getTitle()); } + + @NonNull + public final Preference requirePreference(@StringRes final int resId) { + final Preference preference = findPreference(getString(resId)); + Objects.requireNonNull(preference); + return preference; + } } diff --git a/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java index 9af3666a6..4ef5b8b25 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java @@ -1,21 +1,21 @@ package org.schabi.newpipe.settings; -import android.annotation.SuppressLint; import android.app.Activity; import android.content.Context; +import android.content.DialogInterface; import android.content.Intent; import android.content.SharedPreferences; +import android.net.Uri; import android.os.Bundle; import android.util.Log; import android.widget.Toast; -import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.core.content.ContextCompat; import androidx.preference.Preference; import androidx.preference.PreferenceManager; -import com.nononsenseapps.filepicker.Utils; import com.nostra13.universalimageloader.core.ImageLoader; import org.schabi.newpipe.DownloaderImpl; @@ -26,28 +26,32 @@ import org.schabi.newpipe.error.ReCaptchaActivity; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.localization.ContentCountry; import org.schabi.newpipe.extractor.localization.Localization; -import org.schabi.newpipe.util.FilePathUtils; -import org.schabi.newpipe.util.FilePickerActivityHelper; +import org.schabi.newpipe.streams.io.StoredFileHelper; import org.schabi.newpipe.util.ZipHelper; import java.io.File; import java.text.SimpleDateFormat; import java.util.Date; import java.util.Locale; +import java.util.Objects; +import static org.schabi.newpipe.extractor.utils.Utils.isBlank; import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; public class ContentSettingsFragment extends BasePreferenceFragment { private static final int REQUEST_IMPORT_PATH = 8945; private static final int REQUEST_EXPORT_PATH = 30945; + private static final String ZIP_MIME_TYPE = "application/zip"; + private static final SimpleDateFormat EXPORT_DATE_FORMAT + = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US); private ContentSettingsManager manager; private String importExportDataPathKey; - private String thumbnailLoadToggleKey; private String youtubeRestrictedModeEnabledKey; + @Nullable private Uri lastImportExportDataUri = null; private Localization initialSelectedLocalization; private ContentCountry initialSelectedContentCountry; private String initialLanguage; @@ -55,45 +59,35 @@ public class ContentSettingsFragment extends BasePreferenceFragment { @Override public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { final File homeDir = ContextCompat.getDataDir(requireContext()); + Objects.requireNonNull(homeDir); manager = new ContentSettingsManager(new NewPipeFileLocator(homeDir)); manager.deleteSettingsFile(); - addPreferencesFromResource(R.xml.content_settings); - importExportDataPathKey = getString(R.string.import_export_data_path); - final Preference importDataPreference = findPreference(getString(R.string.import_data)); - importDataPreference.setOnPreferenceClickListener(p -> { - final Intent i = new Intent(getActivity(), FilePickerActivityHelper.class) - .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false) - .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, false) - .putExtra(FilePickerActivityHelper.EXTRA_MODE, - FilePickerActivityHelper.MODE_FILE); - final String path = defaultPreferences.getString(importExportDataPathKey, ""); - if (FilePathUtils.isValidDirectoryPath(path)) { - i.putExtra(FilePickerActivityHelper.EXTRA_START_PATH, path); - } - startActivityForResult(i, REQUEST_IMPORT_PATH); - return true; - }); - - final Preference exportDataPreference = findPreference(getString(R.string.export_data)); - exportDataPreference.setOnPreferenceClickListener(p -> { - final Intent i = new Intent(getActivity(), FilePickerActivityHelper.class) - .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false) - .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, true) - .putExtra(FilePickerActivityHelper.EXTRA_MODE, - FilePickerActivityHelper.MODE_DIR); - final String path = defaultPreferences.getString(importExportDataPathKey, ""); - if (FilePathUtils.isValidDirectoryPath(path)) { - i.putExtra(FilePickerActivityHelper.EXTRA_START_PATH, path); - } - startActivityForResult(i, REQUEST_EXPORT_PATH); - return true; - }); - thumbnailLoadToggleKey = getString(R.string.download_thumbnail_key); youtubeRestrictedModeEnabledKey = getString(R.string.youtube_restricted_mode_enabled); + addPreferencesFromResource(R.xml.content_settings); + + final Preference importDataPreference = requirePreference(R.string.import_data); + importDataPreference.setOnPreferenceClickListener((Preference p) -> { + startActivityForResult( + StoredFileHelper.getPicker(requireContext(), getImportExportDataUri()), + REQUEST_IMPORT_PATH); + return true; + }); + + final Preference exportDataPreference = requirePreference(R.string.export_data); + exportDataPreference.setOnPreferenceClickListener((final Preference p) -> { + + startActivityForResult( + StoredFileHelper.getNewPicker(requireContext(), + "NewPipeData-" + EXPORT_DATE_FORMAT.format(new Date()) + ".zip", + ZIP_MIME_TYPE, getImportExportDataUri()), + REQUEST_EXPORT_PATH); + return true; + }); + initialSelectedLocalization = org.schabi.newpipe.util.Localization .getPreferredLocalization(requireContext()); initialSelectedContentCountry = org.schabi.newpipe.util.Localization @@ -101,8 +95,7 @@ public class ContentSettingsFragment extends BasePreferenceFragment { initialLanguage = PreferenceManager .getDefaultSharedPreferences(requireContext()).getString("app_language_key", "en"); - final Preference clearCookiePref = findPreference(getString(R.string.clear_cookie_key)); - + final Preference clearCookiePref = requirePreference(R.string.clear_cookie_key); clearCookiePref.setOnPreferenceClickListener(preference -> { defaultPreferences.edit() .putString(getString(R.string.recaptcha_cookies_key), "").apply(); @@ -164,8 +157,9 @@ public class ContentSettingsFragment extends BasePreferenceFragment { } @Override - public void onActivityResult(final int requestCode, final int resultCode, - @NonNull final Intent data) { + public void onActivityResult(final int requestCode, + final int resultCode, + @Nullable final Intent data) { assureCorrectAppLanguage(getContext()); super.onActivityResult(requestCode, resultCode, data); if (DEBUG) { @@ -176,51 +170,47 @@ public class ContentSettingsFragment extends BasePreferenceFragment { } if ((requestCode == REQUEST_IMPORT_PATH || requestCode == REQUEST_EXPORT_PATH) - && resultCode == Activity.RESULT_OK && data.getData() != null) { - final File file = Utils.getFileForUri(data.getData()); + && resultCode == Activity.RESULT_OK && data != null && data.getData() != null) { + lastImportExportDataUri = data.getData(); // will be saved only on success + + final StoredFileHelper file + = new StoredFileHelper(getContext(), data.getData(), ZIP_MIME_TYPE); if (requestCode == REQUEST_EXPORT_PATH) { exportDatabase(file); } else { final AlertDialog.Builder builder = new AlertDialog.Builder(requireActivity()); builder.setMessage(R.string.override_current_data) - .setPositiveButton(getString(R.string.finish), - (d, id) -> importDatabase(file)) - .setNegativeButton(android.R.string.cancel, - (d, id) -> d.cancel()); + .setPositiveButton(R.string.finish, + (DialogInterface d, int id) -> importDatabase(file)) + .setNegativeButton(R.string.cancel, + (DialogInterface d, int id) -> d.cancel()); builder.create().show(); } } } - private void exportDatabase(@NonNull final File folder) { + private void exportDatabase(final StoredFileHelper file) { try { - final SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US); - final String path = folder.getAbsolutePath() + "/NewPipeData-" - + sdf.format(new Date()) + ".zip"; - //checkpoint before export NewPipeDatabase.checkpoint(); final SharedPreferences preferences = PreferenceManager - .getDefaultSharedPreferences(requireContext()); - manager.exportDatabase(preferences, path); - - setImportExportDataPath(folder, false); + .getDefaultSharedPreferences(requireContext()); + manager.exportDatabase(preferences, file); + saveLastImportExportDataUri(false); // save export path only on success Toast.makeText(getContext(), R.string.export_complete_toast, Toast.LENGTH_SHORT).show(); } catch (final Exception e) { ErrorActivity.reportUiErrorInSnackbar(this, "Exporting database", e); } } - private void importDatabase(@NonNull final File file) { - final String filePath = file.getAbsolutePath(); - + private void importDatabase(final StoredFileHelper file) { // check if file is supported - if (!ZipHelper.isValidZipFile(filePath)) { + if (!ZipHelper.isValidZipFile(file)) { Toast.makeText(getContext(), R.string.no_valid_zip_file, Toast.LENGTH_SHORT) - .show(); + .show(); return; } @@ -229,29 +219,29 @@ public class ContentSettingsFragment extends BasePreferenceFragment { throw new Exception("Could not create databases dir"); } - if (!manager.extractDb(filePath)) { + if (!manager.extractDb(file)) { Toast.makeText(getContext(), R.string.could_not_import_all_files, Toast.LENGTH_LONG) - .show(); + .show(); } - //If settings file exist, ask if it should be imported. - if (manager.extractSettings(filePath)) { + // if settings file exist, ask if it should be imported. + if (manager.extractSettings(file)) { final AlertDialog.Builder alert = new AlertDialog.Builder(requireContext()); alert.setTitle(R.string.import_settings); alert.setNegativeButton(android.R.string.no, (dialog, which) -> { dialog.dismiss(); - finishImport(file); + finishImport(); }); alert.setPositiveButton(getString(R.string.finish), (dialog, which) -> { dialog.dismiss(); manager.loadSharedPreferences(PreferenceManager - .getDefaultSharedPreferences(requireContext())); - finishImport(file); + .getDefaultSharedPreferences(requireContext())); + finishImport(); }); alert.show(); } else { - finishImport(file); + finishImport(); } } catch (final Exception e) { ErrorActivity.reportUiErrorInSnackbar(this, "Importing database", e); @@ -260,39 +250,29 @@ public class ContentSettingsFragment extends BasePreferenceFragment { /** * Save import path and restart system. - * - * @param file The file of the created backup */ - private void finishImport(@NonNull final File file) { - if (file.getParentFile() != null) { - //immediately because app is about to exit - setImportExportDataPath(file.getParentFile(), true); - } - + private void finishImport() { + // save import path only on success; save immediately because app is about to exit + saveLastImportExportDataUri(true); // restart app to properly load db System.exit(0); } - @SuppressLint("ApplySharedPref") - private void setImportExportDataPath(@NonNull final File file, final boolean immediately) { - final String directoryPath; - if (file.isDirectory()) { - directoryPath = file.getAbsolutePath(); - } else { - final File parentFile = file.getParentFile(); - if (parentFile != null) { - directoryPath = parentFile.getAbsolutePath(); + private Uri getImportExportDataUri() { + final String path = defaultPreferences.getString(importExportDataPathKey, null); + return isBlank(path) ? null : Uri.parse(path); + } + + private void saveLastImportExportDataUri(final boolean immediately) { + if (lastImportExportDataUri != null) { + final SharedPreferences.Editor editor = defaultPreferences.edit() + .putString(importExportDataPathKey, lastImportExportDataUri.toString()); + if (immediately) { + // noinspection ApplySharedPref + editor.commit(); // app about to be restarted, commit immediately } else { - directoryPath = ""; + editor.apply(); } } - final SharedPreferences.Editor editor = defaultPreferences - .edit() - .putString(importExportDataPathKey, directoryPath); - if (immediately) { - editor.commit(); - } else { - editor.apply(); - } } } diff --git a/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsManager.kt b/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsManager.kt index 1730a230e..cb4c14796 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsManager.kt +++ b/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsManager.kt @@ -1,6 +1,8 @@ package org.schabi.newpipe.settings import android.content.SharedPreferences +import org.schabi.newpipe.streams.io.SharpOutputStream +import org.schabi.newpipe.streams.io.StoredFileHelper import org.schabi.newpipe.util.ZipHelper import java.io.BufferedOutputStream import java.io.FileInputStream @@ -17,8 +19,9 @@ class ContentSettingsManager(private val fileLocator: NewPipeFileLocator) { * It also creates the file. */ @Throws(Exception::class) - fun exportDatabase(preferences: SharedPreferences, outputPath: String) { - ZipOutputStream(BufferedOutputStream(FileOutputStream(outputPath))) + fun exportDatabase(preferences: SharedPreferences, file: StoredFileHelper) { + file.create() + ZipOutputStream(BufferedOutputStream(SharpOutputStream(file.stream))) .use { outZip -> ZipHelper.addFileToZip(outZip, fileLocator.db.path, "newpipe.db") @@ -48,8 +51,8 @@ class ContentSettingsManager(private val fileLocator: NewPipeFileLocator) { return fileLocator.dbDir.exists() || fileLocator.dbDir.mkdir() } - fun extractDb(filePath: String): Boolean { - val success = ZipHelper.extractFileFromZip(filePath, fileLocator.db.path, "newpipe.db") + fun extractDb(file: StoredFileHelper): Boolean { + val success = ZipHelper.extractFileFromZip(file, fileLocator.db.path, "newpipe.db") if (success) { fileLocator.dbJournal.delete() fileLocator.dbWal.delete() @@ -59,9 +62,8 @@ class ContentSettingsManager(private val fileLocator: NewPipeFileLocator) { return success } - fun extractSettings(filePath: String): Boolean { - return ZipHelper - .extractFileFromZip(filePath, fileLocator.settings.path, "newpipe.settings") + fun extractSettings(file: StoredFileHelper): Boolean { + return ZipHelper.extractFileFromZip(file, fileLocator.settings.path, "newpipe.settings") } fun loadSharedPreferences(preferences: SharedPreferences) { diff --git a/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java index 913512644..a882acf3b 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java @@ -8,11 +8,12 @@ import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.util.Log; -import android.widget.Toast; +import androidx.annotation.NonNull; import androidx.annotation.StringRes; import androidx.appcompat.app.AlertDialog; import androidx.preference.Preference; +import androidx.preference.SwitchPreferenceCompat; import com.nononsenseapps.filepicker.Utils; @@ -26,7 +27,7 @@ import java.net.URI; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; -import us.shandian.giga.io.StoredDirectoryHelper; +import org.schabi.newpipe.streams.io.StoredDirectoryHelper; import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; @@ -57,13 +58,23 @@ public class DownloadSettingsFragment extends BasePreferenceFragment { prefPathAudio = findPreference(downloadPathAudioPreference); prefStorageAsk = findPreference(downloadStorageAsk); + final SwitchPreferenceCompat prefUseSaf = findPreference(storageUseSafPreference); + prefUseSaf.setDefaultValue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP); + prefUseSaf.setChecked(NewPipeSettings.useStorageAccessFramework(ctx)); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + || Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + prefUseSaf.setEnabled(false); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + prefUseSaf.setSummary(R.string.downloads_storage_use_saf_summary_api_29); + } else { + prefUseSaf.setSummary(R.string.downloads_storage_use_saf_summary_api_19); + } + prefStorageAsk.setSummary(R.string.downloads_storage_ask_summary_no_saf_notice); + } + updatePreferencesSummary(); updatePathPickers(!defaultPreferences.getBoolean(downloadStorageAsk, false)); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - prefStorageAsk.setSummary(R.string.downloads_storage_ask_summary); - } - if (hasInvalidPath(downloadPathVideoPreference) || hasInvalidPath(downloadPathAudioPreference)) { updatePreferencesSummary(); @@ -76,7 +87,7 @@ public class DownloadSettingsFragment extends BasePreferenceFragment { } @Override - public void onAttach(final Context context) { + public void onAttach(@NonNull final Context context) { super.onAttach(context); ctx = context; } @@ -177,8 +188,14 @@ public class DownloadSettingsFragment extends BasePreferenceFragment { final int request; if (key.equals(storageUseSafPreference)) { - Toast.makeText(getContext(), R.string.download_choose_new_path, - Toast.LENGTH_LONG).show(); + if (!NewPipeSettings.useStorageAccessFramework(ctx)) { + NewPipeSettings.saveDefaultVideoDownloadDirectory(ctx); + NewPipeSettings.saveDefaultAudioDownloadDirectory(ctx); + } else { + defaultPreferences.edit().putString(downloadPathVideoPreference, null) + .putString(downloadPathAudioPreference, null).apply(); + } + updatePreferencesSummary(); return true; } else if (key.equals(downloadPathVideoPreference)) { request = REQUEST_DOWNLOAD_VIDEO_PATH; @@ -188,22 +205,7 @@ public class DownloadSettingsFragment extends BasePreferenceFragment { return super.onPreferenceTreeClick(preference); } - final Intent i; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP - && NewPipeSettings.useStorageAccessFramework(ctx)) { - i = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) - .putExtra("android.content.extra.SHOW_ADVANCED", true) - .addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION - | StoredDirectoryHelper.PERMISSION_FLAGS); - } else { - i = new Intent(getActivity(), FilePickerActivityHelper.class) - .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false) - .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, true) - .putExtra(FilePickerActivityHelper.EXTRA_MODE, - FilePickerActivityHelper.MODE_DIR); - } - - startActivityForResult(i, request); + startActivityForResult(StoredDirectoryHelper.getPicker(ctx), request); return true; } diff --git a/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java b/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java index 01f51b0b3..0203f9107 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java +++ b/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java @@ -2,6 +2,7 @@ package org.schabi.newpipe.settings; import android.content.Context; import android.content.SharedPreferences; +import android.os.Build; import android.os.Environment; import androidx.annotation.NonNull; @@ -12,6 +13,8 @@ import org.schabi.newpipe.R; import java.io.File; import java.util.Set; +import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; + /* * Created by k3b on 07.01.2016. * @@ -65,32 +68,36 @@ public final class NewPipeSettings { PreferenceManager.setDefaultValues(context, R.xml.update_settings, true); PreferenceManager.setDefaultValues(context, R.xml.debug_settings, true); - getVideoDownloadFolder(context); - getAudioDownloadFolder(context); + saveDefaultVideoDownloadDirectory(context); + saveDefaultAudioDownloadDirectory(context); SettingMigrations.initMigrations(context, isFirstRun); } - private static void getVideoDownloadFolder(final Context context) { - getDir(context, R.string.download_path_video_key, Environment.DIRECTORY_MOVIES); + static void saveDefaultVideoDownloadDirectory(final Context context) { + saveDefaultDirectory(context, R.string.download_path_video_key, + Environment.DIRECTORY_MOVIES); } - private static void getAudioDownloadFolder(final Context context) { - getDir(context, R.string.download_path_audio_key, Environment.DIRECTORY_MUSIC); + static void saveDefaultAudioDownloadDirectory(final Context context) { + saveDefaultDirectory(context, R.string.download_path_audio_key, + Environment.DIRECTORY_MUSIC); } - private static void getDir(final Context context, final int keyID, - final String defaultDirectoryName) { - final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - final String key = context.getString(keyID); - final String downloadPath = prefs.getString(key, null); - if ((downloadPath != null) && (!downloadPath.isEmpty())) { - return; + private static void saveDefaultDirectory(final Context context, final int keyID, + final String defaultDirectoryName) { + if (!useStorageAccessFramework(context)) { + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + final String key = context.getString(keyID); + final String downloadPath = prefs.getString(key, null); + if (!isNullOrEmpty(downloadPath)) { + return; + } + + final SharedPreferences.Editor spEditor = prefs.edit(); + spEditor.putString(key, getNewPipeChildFolderPathForDir(getDir(defaultDirectoryName))); + spEditor.apply(); } - - final SharedPreferences.Editor spEditor = prefs.edit(); - spEditor.putString(key, getNewPipeChildFolderPathForDir(getDir(defaultDirectoryName))); - spEditor.apply(); } @NonNull @@ -103,10 +110,15 @@ public final class NewPipeSettings { } public static boolean useStorageAccessFramework(final Context context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + return true; + } else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + return false; + } + final String key = context.getString(R.string.storage_use_saf); final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - return prefs.getBoolean(key, false); + return prefs.getBoolean(key, true); } - } diff --git a/app/src/main/java/org/schabi/newpipe/settings/SettingMigrations.java b/app/src/main/java/org/schabi/newpipe/settings/SettingMigrations.java index c59746428..d6aade168 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/SettingMigrations.java +++ b/app/src/main/java/org/schabi/newpipe/settings/SettingMigrations.java @@ -2,6 +2,7 @@ package org.schabi.newpipe.settings; import android.content.Context; import android.content.SharedPreferences; +import android.os.Build; import android.util.Log; import androidx.preference.PreferenceManager; @@ -18,7 +19,7 @@ public final class SettingMigrations { /** * Version number for preferences. Must be incremented every time a migration is necessary. */ - public static final int VERSION = 2; + public static final int VERSION = 3; private static SharedPreferences sp; public static final Migration MIGRATION_0_1 = new Migration(0, 1) { @@ -54,6 +55,20 @@ public final class SettingMigrations { } }; + public static final Migration MIGRATION_2_3 = new Migration(2, 3) { + @Override + protected void migrate(final Context context) { + // Storage Access Framework implementation was improved in #5415, allowing the modern + // and standard way to access folders and files to be used consistently everywhere. + // We reset the setting to its default value, i.e. "use SAF", since now there are no + // more issues with SAF and users should use that one instead of the old + // NoNonsenseFilePicker. SAF does not work on KitKat and below, though, so the setting + // is set to false in that case. + sp.edit().putBoolean(context.getString(R.string.storage_use_saf), + Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP).apply(); + } + }; + /** * List of all implemented migrations. *

@@ -62,7 +77,8 @@ public final class SettingMigrations { */ private static final Migration[] SETTING_MIGRATIONS = { MIGRATION_0_1, - MIGRATION_1_2 + MIGRATION_1_2, + MIGRATION_2_3 }; diff --git a/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java b/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java index 68908fc92..02e2538c5 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java +++ b/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java @@ -1,6 +1,5 @@ package org.schabi.newpipe.settings; -import android.content.Context; import android.os.Bundle; import android.view.Menu; import android.view.MenuItem; @@ -41,11 +40,6 @@ import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; public class SettingsActivity extends AppCompatActivity implements BasePreferenceFragment.OnPreferenceStartFragmentCallback { - - public static void initSettings(final Context context) { - NewPipeSettings.initSettings(context); - } - @Override protected void onCreate(final Bundle savedInstanceBundle) { setTheme(ThemeHelper.getSettingsThemeStyle(this)); diff --git a/app/src/main/java/org/schabi/newpipe/streams/io/SharpInputStream.java b/app/src/main/java/org/schabi/newpipe/streams/io/SharpInputStream.java new file mode 100644 index 000000000..956e9865c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/streams/io/SharpInputStream.java @@ -0,0 +1,52 @@ +package org.schabi.newpipe.streams.io; + +import androidx.annotation.NonNull; + +import java.io.IOException; +import java.io.InputStream; + +/** + * Simply wraps a readable {@link SharpStream} allowing it to be used with built-in Java stuff that + * supports {@link InputStream}. + */ +public class SharpInputStream extends InputStream { + private final SharpStream stream; + + public SharpInputStream(final SharpStream stream) throws IOException { + if (!stream.canRead()) { + throw new IOException("SharpStream is not readable"); + } + this.stream = stream; + } + + @Override + public int read() throws IOException { + return stream.read(); + } + + @Override + public int read(@NonNull final byte[] b) throws IOException { + return stream.read(b); + } + + @Override + public int read(@NonNull final byte[] b, final int off, final int len) throws IOException { + return stream.read(b, off, len); + } + + @Override + public long skip(final long n) throws IOException { + return stream.skip(n); + } + + @Override + public int available() { + final long res = stream.available(); + return res > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) res; + } + + @Override + public void close() { + stream.close(); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/streams/io/SharpOutputStream.java b/app/src/main/java/org/schabi/newpipe/streams/io/SharpOutputStream.java new file mode 100644 index 000000000..76e394312 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/streams/io/SharpOutputStream.java @@ -0,0 +1,46 @@ +package org.schabi.newpipe.streams.io; + +import androidx.annotation.NonNull; + +import java.io.IOException; +import java.io.OutputStream; + +/** + * Simply wraps a writable {@link SharpStream} allowing it to be used with built-in Java stuff that + * supports {@link OutputStream}. + */ +public class SharpOutputStream extends OutputStream { + private final SharpStream stream; + + public SharpOutputStream(final SharpStream stream) throws IOException { + if (!stream.canWrite()) { + throw new IOException("SharpStream is not writable"); + } + this.stream = stream; + } + + @Override + public void write(final int b) throws IOException { + stream.write((byte) b); + } + + @Override + public void write(@NonNull final byte[] b) throws IOException { + stream.write(b); + } + + @Override + public void write(@NonNull final byte[] b, final int off, final int len) throws IOException { + stream.write(b, off, len); + } + + @Override + public void flush() throws IOException { + stream.flush(); + } + + @Override + public void close() { + stream.close(); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/streams/io/SharpStream.java b/app/src/main/java/org/schabi/newpipe/streams/io/SharpStream.java index 46ec68d9e..849c7c051 100644 --- a/app/src/main/java/org/schabi/newpipe/streams/io/SharpStream.java +++ b/app/src/main/java/org/schabi/newpipe/streams/io/SharpStream.java @@ -1,12 +1,20 @@ package org.schabi.newpipe.streams.io; import java.io.Closeable; +import java.io.Flushable; import java.io.IOException; /** - * Based on C#'s Stream class. + * Based on C#'s Stream class. SharpStream is a wrapper around the 2 different APIs for SAF + * ({@link us.shandian.giga.io.FileStreamSAF}) and non-SAF ({@link us.shandian.giga.io.FileStream}). + * It has both input and output like in C#, while in Java those are usually different classes. + * {@link SharpInputStream} and {@link SharpOutputStream} are simple classes that wrap + * {@link SharpStream} and extend respectively {@link java.io.InputStream} and + * {@link java.io.OutputStream}, since unfortunately a class can only extend one class, so that a + * sharp stream can be used with built-in Java stuff that supports {@link java.io.InputStream} + * or {@link java.io.OutputStream}. */ -public abstract class SharpStream implements Closeable { +public abstract class SharpStream implements Closeable, Flushable { public abstract int read() throws IOException; public abstract int read(byte[] buffer) throws IOException; diff --git a/app/src/main/java/us/shandian/giga/io/StoredDirectoryHelper.java b/app/src/main/java/org/schabi/newpipe/streams/io/StoredDirectoryHelper.java similarity index 50% rename from app/src/main/java/us/shandian/giga/io/StoredDirectoryHelper.java rename to app/src/main/java/org/schabi/newpipe/streams/io/StoredDirectoryHelper.java index 5edc5f3ed..feca89f02 100644 --- a/app/src/main/java/us/shandian/giga/io/StoredDirectoryHelper.java +++ b/app/src/main/java/org/schabi/newpipe/streams/io/StoredDirectoryHelper.java @@ -1,6 +1,5 @@ -package us.shandian.giga.io; +package org.schabi.newpipe.streams.io; -import android.annotation.TargetApi; import android.content.ContentResolver; import android.content.Context; import android.content.Intent; @@ -13,6 +12,9 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.documentfile.provider.DocumentFile; +import org.schabi.newpipe.settings.NewPipeSettings; +import org.schabi.newpipe.util.FilePickerActivityHelper; + import java.io.File; import java.io.IOException; import java.net.URI; @@ -21,10 +23,11 @@ import java.util.Collections; import static android.provider.DocumentsContract.Document.COLUMN_DISPLAY_NAME; import static android.provider.DocumentsContract.Root.COLUMN_DOCUMENT_ID; - +import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; public class StoredDirectoryHelper { - public final static int PERMISSION_FLAGS = Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION; + public static final int PERMISSION_FLAGS = Intent.FLAG_GRANT_READ_URI_PERMISSION + | Intent.FLAG_GRANT_WRITE_URI_PERMISSION; private File ioTree; private DocumentFile docTree; @@ -33,7 +36,8 @@ public class StoredDirectoryHelper { private final String tag; - public StoredDirectoryHelper(@NonNull Context context, @NonNull Uri path, String tag) throws IOException { + public StoredDirectoryHelper(@NonNull final Context context, @NonNull final Uri path, + final String tag) throws IOException { this.tag = tag; if (ContentResolver.SCHEME_FILE.equalsIgnoreCase(path.getScheme())) { @@ -45,51 +49,49 @@ public class StoredDirectoryHelper { try { this.context.getContentResolver().takePersistableUriPermission(path, PERMISSION_FLAGS); - } catch (Exception e) { + } catch (final Exception e) { throw new IOException(e); } - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { throw new IOException("Storage Access Framework with Directory API is not available"); + } this.docTree = DocumentFile.fromTreeUri(context, path); - if (this.docTree == null) + if (this.docTree == null) { throw new IOException("Failed to create the tree from Uri"); + } } - @TargetApi(Build.VERSION_CODES.KITKAT) - public StoredDirectoryHelper(@NonNull URI location, String tag) { - ioTree = new File(location); - this.tag = tag; - } - - public StoredFileHelper createFile(String filename, String mime) { + public StoredFileHelper createFile(final String filename, final String mime) { return createFile(filename, mime, false); } - public StoredFileHelper createUniqueFile(String name, String mime) { - ArrayList matches = new ArrayList<>(); - String[] filename = splitFilename(name); - String lcFilename = filename[0].toLowerCase(); + public StoredFileHelper createUniqueFile(final String name, final String mime) { + final ArrayList matches = new ArrayList<>(); + final String[] filename = splitFilename(name); + final String lcFilename = filename[0].toLowerCase(); if (docTree == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { - for (File file : ioTree.listFiles()) + for (final File file : ioTree.listFiles()) { addIfStartWith(matches, lcFilename, file.getName()); + } } else { // warning: SAF file listing is very slow - Uri docTreeChildren = DocumentsContract.buildChildDocumentsUriUsingTree( - docTree.getUri(), DocumentsContract.getDocumentId(docTree.getUri()) - ); + final Uri docTreeChildren = DocumentsContract.buildChildDocumentsUriUsingTree( + docTree.getUri(), DocumentsContract.getDocumentId(docTree.getUri())); - String[] projection = {COLUMN_DISPLAY_NAME}; - String selection = "(LOWER(" + COLUMN_DISPLAY_NAME + ") LIKE ?%"; - ContentResolver cr = context.getContentResolver(); + final String[] projection = new String[]{COLUMN_DISPLAY_NAME}; + final String selection = "(LOWER(" + COLUMN_DISPLAY_NAME + ") LIKE ?%"; + final ContentResolver cr = context.getContentResolver(); - try (Cursor cursor = cr.query(docTreeChildren, projection, selection, new String[]{lcFilename}, null)) { + try (Cursor cursor = cr.query(docTreeChildren, projection, selection, + new String[]{lcFilename}, null)) { if (cursor != null) { - while (cursor.moveToNext()) + while (cursor.moveToNext()) { addIfStartWith(matches, lcFilename, cursor.getString(0)); + } } } } @@ -99,7 +101,7 @@ public class StoredDirectoryHelper { } else { // check if the filename is in use String lcName = name.toLowerCase(); - for (String testName : matches) { + for (final String testName : matches) { if (testName.equals(lcName)) { lcName = null; break; @@ -107,28 +109,34 @@ public class StoredDirectoryHelper { } // check if not in use - if (lcName != null) return createFile(name, mime, true); + if (lcName != null) { + return createFile(name, mime, true); + } } Collections.sort(matches, String::compareTo); for (int i = 1; i < 1000; i++) { - if (Collections.binarySearch(matches, makeFileName(lcFilename, i, filename[1])) < 0) + if (Collections.binarySearch(matches, makeFileName(lcFilename, i, filename[1])) < 0) { return createFile(makeFileName(filename[0], i, filename[1]), mime, true); + } } - return createFile(String.valueOf(System.currentTimeMillis()).concat(filename[1]), mime, false); + return createFile(String.valueOf(System.currentTimeMillis()).concat(filename[1]), mime, + false); } - private StoredFileHelper createFile(String filename, String mime, boolean safe) { - StoredFileHelper storage; + private StoredFileHelper createFile(final String filename, final String mime, + final boolean safe) { + final StoredFileHelper storage; try { - if (docTree == null) + if (docTree == null) { storage = new StoredFileHelper(ioTree, filename, mime); - else + } else { storage = new StoredFileHelper(context, docTree, filename, mime, safe); - } catch (IOException e) { + } + } catch (final IOException e) { return null; } @@ -146,7 +154,7 @@ public class StoredDirectoryHelper { } /** - * Indicates whatever if is possible access using the {@code java.io} API + * Indicates whether it's using the {@code java.io} API. * * @return {@code true} for Java I/O API, otherwise, {@code false} for Storage Access Framework */ @@ -169,7 +177,9 @@ public class StoredDirectoryHelper { return ioTree.exists() || ioTree.mkdirs(); } - if (docTree.exists()) return true; + if (docTree.exists()) { + return true; + } try { DocumentFile parent; @@ -177,14 +187,18 @@ public class StoredDirectoryHelper { while (true) { parent = docTree.getParentFile(); - if (parent == null || child == null) break; - if (parent.exists()) return true; + if (parent == null || child == null) { + break; + } + if (parent.exists()) { + return true; + } parent.createDirectory(child); - child = parent.getName();// for the next iteration + child = parent.getName(); // for the next iteration } - } catch (Exception e) { + } catch (final Exception ignored) { // no more parent directories or unsupported by the storage provider } @@ -195,13 +209,13 @@ public class StoredDirectoryHelper { return tag; } - public Uri findFile(String filename) { + public Uri findFile(final String filename) { if (docTree == null) { - File res = new File(ioTree, filename); + final File res = new File(ioTree, filename); return res.exists() ? Uri.fromFile(res) : null; } - DocumentFile res = findFileSAFHelper(context, docTree, filename); + final DocumentFile res = findFileSAFHelper(context, docTree, filename); return res == null ? null : res.getUri(); } @@ -209,82 +223,115 @@ public class StoredDirectoryHelper { return docTree == null ? ioTree.canWrite() : docTree.canWrite(); } + /** + * @return {@code false} if the storage is direct, or the SAF storage is valid; {@code true} if + * SAF access to this SAF storage is denied (e.g. the user clicked on {@code Android settings -> + * Apps & notifications -> NewPipe -> Storage & cache -> Clear access}); + */ + public boolean isInvalidSafStorage() { + return docTree != null && docTree.getName() == null; + } + @NonNull @Override public String toString() { return (docTree == null ? Uri.fromFile(ioTree) : docTree.getUri()).toString(); } - //////////////////// // Utils /////////////////// - private static void addIfStartWith(ArrayList list, @NonNull String base, String str) { - if (str == null || str.isEmpty()) return; - str = str.toLowerCase(); - if (str.startsWith(base)) list.add(str); + private static void addIfStartWith(final ArrayList list, @NonNull final String base, + final String str) { + if (isNullOrEmpty(str)) { + return; + } + final String lowerStr = str.toLowerCase(); + if (lowerStr.startsWith(base)) { + list.add(lowerStr); + } } - private static String[] splitFilename(@NonNull String filename) { - int dotIndex = filename.lastIndexOf('.'); + private static String[] splitFilename(@NonNull final String filename) { + final int dotIndex = filename.lastIndexOf('.'); - if (dotIndex < 0 || (dotIndex == filename.length() - 1)) + if (dotIndex < 0 || (dotIndex == filename.length() - 1)) { return new String[]{filename, ""}; + } return new String[]{filename.substring(0, dotIndex), filename.substring(dotIndex)}; } - private static String makeFileName(String name, int idx, String ext) { + private static String makeFileName(final String name, final int idx, final String ext) { return name.concat(" (").concat(String.valueOf(idx)).concat(")").concat(ext); } /** - * Fast (but not enough) file/directory finder under the storage access framework + * Fast (but not enough) file/directory finder under the storage access framework. * * @param context The context * @param tree Directory where search * @param filename Target filename * @return A {@link DocumentFile} contain the reference, otherwise, null */ - static DocumentFile findFileSAFHelper(@Nullable Context context, DocumentFile tree, String filename) { + static DocumentFile findFileSAFHelper(@Nullable final Context context, final DocumentFile tree, + final String filename) { if (context == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { - return tree.findFile(filename);// warning: this is very slow + return tree.findFile(filename); // warning: this is very slow } - if (!tree.canRead()) return null;// missing read permission + if (!tree.canRead()) { + return null; // missing read permission + } final int name = 0; final int documentId = 1; // LOWER() SQL function is not supported - String selection = COLUMN_DISPLAY_NAME + " = ?"; - //String selection = COLUMN_DISPLAY_NAME + " LIKE ?%"; + final String selection = COLUMN_DISPLAY_NAME + " = ?"; + //final String selection = COLUMN_DISPLAY_NAME + " LIKE ?%"; - Uri childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree( - tree.getUri(), DocumentsContract.getDocumentId(tree.getUri()) - ); - String[] projection = {COLUMN_DISPLAY_NAME, COLUMN_DOCUMENT_ID}; - ContentResolver contentResolver = context.getContentResolver(); + final Uri childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(tree.getUri(), + DocumentsContract.getDocumentId(tree.getUri())); + final String[] projection = {COLUMN_DISPLAY_NAME, COLUMN_DOCUMENT_ID}; + final ContentResolver contentResolver = context.getContentResolver(); - filename = filename.toLowerCase(); + final String lowerFilename = filename.toLowerCase(); - try (Cursor cursor = contentResolver.query(childrenUri, projection, selection, new String[]{filename}, null)) { - if (cursor == null) return null; + try (Cursor cursor = contentResolver.query(childrenUri, projection, selection, + new String[]{lowerFilename}, null)) { + if (cursor == null) { + return null; + } while (cursor.moveToNext()) { - if (cursor.isNull(name) || !cursor.getString(name).toLowerCase().startsWith(filename)) + if (cursor.isNull(name) + || !cursor.getString(name).toLowerCase().startsWith(lowerFilename)) { continue; + } - return DocumentFile.fromSingleUri( - context, DocumentsContract.buildDocumentUriUsingTree( - tree.getUri(), cursor.getString(documentId) - ) - ); + return DocumentFile.fromSingleUri(context, + DocumentsContract.buildDocumentUriUsingTree(tree.getUri(), + cursor.getString(documentId))); } } return null; } + public static Intent getPicker(final Context ctx) { + if (NewPipeSettings.useStorageAccessFramework(ctx)) { + return new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) + .putExtra("android.content.extra.SHOW_ADVANCED", true) + .addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + | StoredDirectoryHelper.PERMISSION_FLAGS); + } else { + return new Intent(ctx, FilePickerActivityHelper.class) + .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false) + .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, true) + .putExtra(FilePickerActivityHelper.EXTRA_MODE, + FilePickerActivityHelper.MODE_DIR); + } + } } diff --git a/app/src/main/java/org/schabi/newpipe/streams/io/StoredFileHelper.java b/app/src/main/java/org/schabi/newpipe/streams/io/StoredFileHelper.java new file mode 100644 index 000000000..c86164ed2 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/streams/io/StoredFileHelper.java @@ -0,0 +1,554 @@ +package org.schabi.newpipe.streams.io; + +import android.annotation.TargetApi; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.provider.DocumentsContract; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.documentfile.provider.DocumentFile; + +import com.nononsenseapps.filepicker.Utils; + +import org.schabi.newpipe.settings.NewPipeSettings; +import org.schabi.newpipe.util.FilePickerActivityHelper; + +import java.io.File; +import java.io.IOException; +import java.io.Serializable; +import java.net.URI; + +import us.shandian.giga.io.FileStream; +import us.shandian.giga.io.FileStreamSAF; + +public class StoredFileHelper implements Serializable { + private static final long serialVersionUID = 0L; + public static final String DEFAULT_MIME = "application/octet-stream"; + + private transient DocumentFile docFile; + private transient DocumentFile docTree; + private transient File ioFile; + private transient Context context; + + protected String source; + private String sourceTree; + + protected String tag; + + private String srcName; + private String srcType; + + public StoredFileHelper(final Context context, final Uri uri, final String mime) { + if (FilePickerActivityHelper.isOwnFileUri(context, uri)) { + ioFile = Utils.getFileForUri(uri); + source = Uri.fromFile(ioFile).toString(); + } else { + docFile = DocumentFile.fromSingleUri(context, uri); + source = uri.toString(); + } + + this.context = context; + this.srcType = mime; + } + + public StoredFileHelper(@Nullable final Uri parent, final String filename, final String mime, + final String tag) { + this.source = null; // this instance will be "invalid" see invalidate()/isInvalid() methods + + this.srcName = filename; + this.srcType = mime == null ? DEFAULT_MIME : mime; + if (parent != null) { + this.sourceTree = parent.toString(); + } + + this.tag = tag; + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + StoredFileHelper(@Nullable final Context context, final DocumentFile tree, + final String filename, final String mime, final boolean safe) + throws IOException { + this.docTree = tree; + this.context = context; + + final DocumentFile res; + + if (safe) { + // no conflicts (the filename is not in use) + res = this.docTree.createFile(mime, filename); + if (res == null) { + throw new IOException("Cannot create the file"); + } + } else { + res = createSAF(context, mime, filename); + } + + this.docFile = res; + + this.source = docFile.getUri().toString(); + this.sourceTree = docTree.getUri().toString(); + + this.srcName = this.docFile.getName(); + this.srcType = this.docFile.getType(); + } + + StoredFileHelper(final File location, final String filename, final String mime) + throws IOException { + this.ioFile = new File(location, filename); + + if (this.ioFile.exists()) { + if (!this.ioFile.isFile() && !this.ioFile.delete()) { + throw new IOException("The filename is already in use by non-file entity " + + "and cannot overwrite it"); + } + } else { + if (!this.ioFile.createNewFile()) { + throw new IOException("Cannot create the file"); + } + } + + this.source = Uri.fromFile(this.ioFile).toString(); + this.sourceTree = Uri.fromFile(location).toString(); + + this.srcName = ioFile.getName(); + this.srcType = mime; + } + + @TargetApi(Build.VERSION_CODES.KITKAT) + public StoredFileHelper(final Context context, @Nullable final Uri parent, + @NonNull final Uri path, final String tag) throws IOException { + this.tag = tag; + this.source = path.toString(); + + if (path.getScheme() == null + || path.getScheme().equalsIgnoreCase(ContentResolver.SCHEME_FILE)) { + this.ioFile = new File(URI.create(this.source)); + } else { + final DocumentFile file = DocumentFile.fromSingleUri(context, path); + + if (file == null) { + throw new RuntimeException("SAF not available"); + } + + this.context = context; + + if (file.getName() == null) { + this.source = null; + return; + } else { + this.docFile = file; + takePermissionSAF(); + } + } + + if (parent != null) { + if (!ContentResolver.SCHEME_FILE.equals(parent.getScheme())) { + this.docTree = DocumentFile.fromTreeUri(context, parent); + } + + this.sourceTree = parent.toString(); + } + + this.srcName = getName(); + this.srcType = getType(); + } + + + public static StoredFileHelper deserialize(@NonNull final StoredFileHelper storage, + final Context context) throws IOException { + final Uri treeUri = storage.sourceTree == null ? null : Uri.parse(storage.sourceTree); + + if (storage.isInvalid()) { + return new StoredFileHelper(treeUri, storage.srcName, storage.srcType, storage.tag); + } + + final StoredFileHelper instance = new StoredFileHelper(context, treeUri, + Uri.parse(storage.source), storage.tag); + + // under SAF, if the target document is deleted, conserve the filename and mime + if (instance.srcName == null) { + instance.srcName = storage.srcName; + } + if (instance.srcType == null) { + instance.srcType = storage.srcType; + } + + return instance; + } + + public SharpStream getStream() throws IOException { + assertValid(); + + if (docFile == null) { + return new FileStream(ioFile); + } else { + return new FileStreamSAF(context.getContentResolver(), docFile.getUri()); + } + } + + /** + * Indicates whether it's using the {@code java.io} API. + * + * @return {@code true} for Java I/O API, otherwise, {@code false} for Storage Access Framework + */ + public boolean isDirect() { + assertValid(); + + return docFile == null; + } + + public boolean isInvalid() { + return source == null; + } + + public Uri getUri() { + assertValid(); + + return docFile == null ? Uri.fromFile(ioFile) : docFile.getUri(); + } + + public Uri getParentUri() { + assertValid(); + + return sourceTree == null ? null : Uri.parse(sourceTree); + } + + public void truncate() throws IOException { + assertValid(); + + try (SharpStream fs = getStream()) { + fs.setLength(0); + } + } + + public boolean delete() { + if (source == null) { + return true; + } + if (docFile == null) { + return ioFile.delete(); + } + + final boolean res = docFile.delete(); + + try { + final int flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + | Intent.FLAG_GRANT_WRITE_URI_PERMISSION; + context.getContentResolver().releasePersistableUriPermission(docFile.getUri(), flags); + } catch (final Exception ex) { + // nothing to do + } + + return res; + } + + public long length() { + assertValid(); + + return docFile == null ? ioFile.length() : docFile.length(); + } + + public boolean canWrite() { + if (source == null) { + return false; + } + return docFile == null ? ioFile.canWrite() : docFile.canWrite(); + } + + public String getName() { + if (source == null) { + return srcName; + } else if (docFile == null) { + return ioFile.getName(); + } + + final String name = docFile.getName(); + return name == null ? srcName : name; + } + + public String getType() { + if (source == null || docFile == null) { + return srcType; + } + + final String type = docFile.getType(); + return type == null ? srcType : type; + } + + public String getTag() { + return tag; + } + + public boolean existsAsFile() { + if (source == null) { + return false; + } + + // WARNING: DocumentFile.exists() and DocumentFile.isFile() methods are slow + // docFile.isVirtual() means it is non-physical? + return docFile == null + ? (ioFile.exists() && ioFile.isFile()) + : (docFile.exists() && docFile.isFile()); + } + + public boolean create() { + assertValid(); + final boolean result; + + if (docFile == null) { + try { + result = ioFile.createNewFile(); + } catch (final IOException e) { + return false; + } + } else if (docTree == null) { + result = false; + } else { + if (!docTree.canRead() || !docTree.canWrite()) { + return false; + } + try { + docFile = createSAF(context, srcType, srcName); + if (docFile.getName() == null) { + return false; + } + result = true; + } catch (final IOException e) { + return false; + } + } + + if (result) { + source = (docFile == null ? Uri.fromFile(ioFile) : docFile.getUri()).toString(); + srcName = getName(); + srcType = getType(); + } + + return result; + } + + public void invalidate() { + if (source == null) { + return; + } + + srcName = getName(); + srcType = getType(); + + source = null; + + docTree = null; + docFile = null; + ioFile = null; + context = null; + } + + public boolean equals(final StoredFileHelper storage) { + if (this == storage) { + return true; + } + + // note: do not compare tags, files can have the same parent folder + //if (stringMismatch(this.tag, storage.tag)) return false; + + if (stringMismatch(getLowerCase(this.sourceTree), getLowerCase(this.sourceTree))) { + return false; + } + + if (this.isInvalid() || storage.isInvalid()) { + if (this.srcName == null || storage.srcName == null || this.srcType == null + || storage.srcType == null) { + return false; + } + + return this.srcName.equalsIgnoreCase(storage.srcName) + && this.srcType.equalsIgnoreCase(storage.srcType); + } + + if (this.isDirect() != storage.isDirect()) { + return false; + } + + if (this.isDirect()) { + return this.ioFile.getPath().equalsIgnoreCase(storage.ioFile.getPath()); + } + + return DocumentsContract.getDocumentId(this.docFile.getUri()) + .equalsIgnoreCase(DocumentsContract.getDocumentId(storage.docFile.getUri())); + } + + @NonNull + @Override + public String toString() { + if (source == null) { + return "[Invalid state] name=" + srcName + " type=" + srcType + " tag=" + tag; + } else { + return "sourceFile=" + source + " treeSource=" + (sourceTree == null ? "" : sourceTree) + + " tag=" + tag; + } + } + + + private void assertValid() { + if (source == null) { + throw new IllegalStateException("In invalid state"); + } + } + + private void takePermissionSAF() throws IOException { + try { + context.getContentResolver().takePersistableUriPermission(docFile.getUri(), + StoredDirectoryHelper.PERMISSION_FLAGS); + } catch (final Exception e) { + if (docFile.getName() == null) { + throw new IOException(e); + } + } + } + + @NonNull + private DocumentFile createSAF(@Nullable final Context ctx, final String mime, + final String filename) throws IOException { + DocumentFile res = StoredDirectoryHelper.findFileSAFHelper(ctx, docTree, filename); + + if (res != null && res.exists() && res.isDirectory()) { + if (!res.delete()) { + throw new IOException("Directory with the same name found but cannot delete"); + } + res = null; + } + + if (res == null) { + res = this.docTree.createFile(srcType == null ? DEFAULT_MIME : mime, filename); + if (res == null) { + throw new IOException("Cannot create the file"); + } + } + + return res; + } + + private String getLowerCase(final String str) { + return str == null ? null : str.toLowerCase(); + } + + private boolean stringMismatch(final String str1, final String str2) { + if (str1 == null && str2 == null) { + return false; + } + if ((str1 == null) != (str2 == null)) { + return true; + } + + return !str1.equals(str2); + } + + public static Intent getPicker(@NonNull final Context ctx) { + if (NewPipeSettings.useStorageAccessFramework(ctx)) { + return new Intent(Intent.ACTION_OPEN_DOCUMENT) + .putExtra("android.content.extra.SHOW_ADVANCED", true) + .setType("*/*") + .addCategory(Intent.CATEGORY_OPENABLE) + .addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + | StoredDirectoryHelper.PERMISSION_FLAGS); + } else { + return new Intent(ctx, FilePickerActivityHelper.class) + .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false) + .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, true) + .putExtra(FilePickerActivityHelper.EXTRA_SINGLE_CLICK, true) + .putExtra(FilePickerActivityHelper.EXTRA_MODE, + FilePickerActivityHelper.MODE_FILE); + } + } + + public static Intent getPicker(@NonNull final Context ctx, @Nullable final Uri initialPath) { + return applyInitialPathToPickerIntent(ctx, getPicker(ctx), initialPath, null); + } + + public static Intent getNewPicker(@NonNull final Context ctx, + @Nullable final String filename, + @NonNull final String mimeType, + @Nullable final Uri initialPath) { + final Intent i; + if (NewPipeSettings.useStorageAccessFramework(ctx)) { + i = new Intent(Intent.ACTION_CREATE_DOCUMENT) + .putExtra("android.content.extra.SHOW_ADVANCED", true) + .setType(mimeType) + .addCategory(Intent.CATEGORY_OPENABLE) + .addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + | StoredDirectoryHelper.PERMISSION_FLAGS); + if (filename != null) { + i.putExtra(Intent.EXTRA_TITLE, filename); + } + } else { + i = new Intent(ctx, FilePickerActivityHelper.class) + .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false) + .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, true) + .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_EXISTING_FILE, true) + .putExtra(FilePickerActivityHelper.EXTRA_MODE, + FilePickerActivityHelper.MODE_NEW_FILE); + } + return applyInitialPathToPickerIntent(ctx, i, initialPath, filename); + } + + private static Intent applyInitialPathToPickerIntent(@NonNull final Context ctx, + @NonNull final Intent intent, + @Nullable final Uri initialPath, + @Nullable final String filename) { + + if (NewPipeSettings.useStorageAccessFramework(ctx)) { + if (initialPath == null) { + return intent; // nothing to do, no initial path provided + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + return intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, initialPath); + } else { + return intent; // can't set initial path on API < 26 + } + + } else { + if (initialPath == null && filename == null) { + return intent; // nothing to do, no initial path and no file name provided + } + + File file; + if (initialPath == null) { + // The only way to set the previewed filename in non-SAF FilePicker is to set a + // starting path ending with that filename. So when the initialPath is null but + // filename isn't just default to the external storage directory. + file = Environment.getExternalStorageDirectory(); + } else { + try { + file = Utils.getFileForUri(initialPath); + } catch (final Throwable ignored) { + // getFileForUri() can't decode paths to 'storage', fallback to this + file = new File(initialPath.toString()); + } + } + + // remove any filename at the end of the path (get the parent directory in that case) + if (!file.exists() || !file.isDirectory()) { + file = file.getParentFile(); + if (file == null || !file.exists()) { + // default to the external storage directory in case of an invalid path + file = Environment.getExternalStorageDirectory(); + } + // else: file is surely a directory + } + + if (filename != null) { + // append a filename so that the non-SAF FilePicker shows it as the preview + file = new File(file, filename); + } + + return intent + .putExtra(FilePickerActivityHelper.EXTRA_START_PATH, file.getAbsolutePath()); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/FilePathUtils.java b/app/src/main/java/org/schabi/newpipe/util/FilePathUtils.java deleted file mode 100644 index 4162e563a..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/FilePathUtils.java +++ /dev/null @@ -1,22 +0,0 @@ -package org.schabi.newpipe.util; - -import java.io.File; - -public final class FilePathUtils { - private FilePathUtils() { } - - - /** - * Check that the path is a valid directory path and it exists. - * - * @param path full path of directory, - * @return is path valid or not - */ - public static boolean isValidDirectoryPath(final String path) { - if (path == null || path.isEmpty()) { - return false; - } - final File file = new File(path); - return file.exists() && file.isDirectory(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/FilePickerActivityHelper.java b/app/src/main/java/org/schabi/newpipe/util/FilePickerActivityHelper.java index 6ede163a3..20d8ce30c 100644 --- a/app/src/main/java/org/schabi/newpipe/util/FilePickerActivityHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/FilePickerActivityHelper.java @@ -1,7 +1,6 @@ package org.schabi.newpipe.util; import android.content.Context; -import android.content.Intent; import android.net.Uri; import android.os.Bundle; import android.os.Environment; @@ -28,25 +27,6 @@ import java.io.File; public class FilePickerActivityHelper extends com.nononsenseapps.filepicker.FilePickerActivity { private CustomFilePickerFragment currentFragment; - public static Intent chooseSingleFile(@NonNull final Context context) { - return new Intent(context, FilePickerActivityHelper.class) - .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false) - .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, false) - .putExtra(FilePickerActivityHelper.EXTRA_SINGLE_CLICK, true) - .putExtra(FilePickerActivityHelper.EXTRA_MODE, FilePickerActivityHelper.MODE_FILE); - } - - public static Intent chooseFileToSave(@NonNull final Context context, - @Nullable final String startPath) { - return new Intent(context, FilePickerActivityHelper.class) - .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false) - .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, true) - .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_EXISTING_FILE, true) - .putExtra(FilePickerActivityHelper.EXTRA_START_PATH, startPath) - .putExtra(FilePickerActivityHelper.EXTRA_MODE, - FilePickerActivityHelper.MODE_NEW_FILE); - } - public static boolean isOwnFileUri(@NonNull final Context context, @NonNull final Uri uri) { if (uri.getAuthority() == null) { return false; diff --git a/app/src/main/java/org/schabi/newpipe/util/PermissionHelper.java b/app/src/main/java/org/schabi/newpipe/util/PermissionHelper.java index 03400bdbb..c64631b72 100644 --- a/app/src/main/java/org/schabi/newpipe/util/PermissionHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/PermissionHelper.java @@ -18,6 +18,7 @@ import androidx.core.app.ActivityCompat; import androidx.core.content.ContextCompat; import org.schabi.newpipe.R; +import org.schabi.newpipe.settings.NewPipeSettings; public final class PermissionHelper { public static final int DOWNLOAD_DIALOG_REQUEST_CODE = 778; @@ -26,6 +27,10 @@ public final class PermissionHelper { private PermissionHelper() { } public static boolean checkStoragePermissions(final Activity activity, final int requestCode) { + if (NewPipeSettings.useStorageAccessFramework(activity)) { + return true; // Storage permissions are not needed for SAF + } + if (!checkReadStoragePermissions(activity, requestCode)) { return false; } diff --git a/app/src/main/java/org/schabi/newpipe/util/ZipHelper.java b/app/src/main/java/org/schabi/newpipe/util/ZipHelper.java index e2b766bb0..bc08e6197 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ZipHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ZipHelper.java @@ -1,15 +1,18 @@ package org.schabi.newpipe.util; +import org.schabi.newpipe.streams.io.SharpInputStream; + import java.io.BufferedInputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.util.zip.ZipEntry; -import java.util.zip.ZipFile; import java.util.zip.ZipInputStream; import java.util.zip.ZipOutputStream; +import org.schabi.newpipe.streams.io.StoredFileHelper; + /** * Created by Christian Schabesberger on 28.01.18. * Copyright 2018 Christian Schabesberger @@ -59,24 +62,23 @@ public final class ZipHelper { } /** - * This will extract data from Zipfiles. + * This will extract data from ZipInputStream. * Caution this will override the original file. * - * @param filePath The path of the zip + * @param zipFile The zip file * @param file The path of the file on the disk where the data should be extracted to. * @param name The path of the file inside the zip. * @return will return true if the file was found within the zip file * @throws Exception */ - public static boolean extractFileFromZip(final String filePath, final String file, + public static boolean extractFileFromZip(final StoredFileHelper zipFile, final String file, final String name) throws Exception { try (ZipInputStream inZip = new ZipInputStream(new BufferedInputStream( - new FileInputStream(filePath)))) { + new SharpInputStream(zipFile.getStream())))) { final byte[] data = new byte[BUFFER_SIZE]; - boolean found = false; - ZipEntry ze; + while ((ze = inZip.getNextEntry()) != null) { if (ze.getName().equals(name)) { found = true; @@ -102,8 +104,9 @@ public final class ZipHelper { } } - public static boolean isValidZipFile(final String filePath) { - try (ZipFile ignored = new ZipFile(filePath)) { + public static boolean isValidZipFile(final StoredFileHelper file) { + try (ZipInputStream ignored = new ZipInputStream(new BufferedInputStream( + new SharpInputStream(file.getStream())))) { return true; } catch (final IOException ioe) { return false; 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 2b3faa3e0..628058f55 100644 --- a/app/src/main/java/us/shandian/giga/get/DownloadMission.java +++ b/app/src/main/java/us/shandian/giga/get/DownloadMission.java @@ -26,7 +26,7 @@ import java.util.Objects; import javax.net.ssl.SSLException; -import us.shandian.giga.io.StoredFileHelper; +import org.schabi.newpipe.streams.io.StoredFileHelper; import us.shandian.giga.postprocessing.Postprocessing; import us.shandian.giga.service.DownloadManagerService; import us.shandian.giga.util.Utility; diff --git a/app/src/main/java/us/shandian/giga/get/Mission.java b/app/src/main/java/us/shandian/giga/get/Mission.java index ecb0eaebd..77b9c1e33 100644 --- a/app/src/main/java/us/shandian/giga/get/Mission.java +++ b/app/src/main/java/us/shandian/giga/get/Mission.java @@ -5,7 +5,7 @@ import androidx.annotation.NonNull; import java.io.Serializable; import java.util.Calendar; -import us.shandian.giga.io.StoredFileHelper; +import org.schabi.newpipe.streams.io.StoredFileHelper; public abstract class Mission implements Serializable { private static final long serialVersionUID = 1L;// last bump: 27 march 2019 @@ -25,6 +25,10 @@ public abstract class Mission implements Serializable { */ public long timestamp; + public long getTimestamp() { + return timestamp; + } + /** * pre-defined content type */ @@ -35,10 +39,6 @@ public abstract class Mission implements Serializable { */ public StoredFileHelper storage; - public long getTimestamp() { - return timestamp; - } - /** * Delete the downloaded file * @@ -57,7 +57,7 @@ public abstract class Mission implements Serializable { @NonNull @Override public String toString() { - Calendar calendar = Calendar.getInstance(); + final Calendar calendar = Calendar.getInstance(); calendar.setTimeInMillis(timestamp); return "[" + calendar.getTime().toString() + "] " + (storage.isInvalid() ? storage.getName() : storage.getUri()); } diff --git a/app/src/main/java/us/shandian/giga/get/sqlite/FinishedMissionStore.java b/app/src/main/java/us/shandian/giga/get/sqlite/FinishedMissionStore.java index 15c45c6fd..704385212 100644 --- a/app/src/main/java/us/shandian/giga/get/sqlite/FinishedMissionStore.java +++ b/app/src/main/java/us/shandian/giga/get/sqlite/FinishedMissionStore.java @@ -17,7 +17,7 @@ import java.util.Objects; import us.shandian.giga.get.DownloadMission; import us.shandian.giga.get.FinishedMission; import us.shandian.giga.get.Mission; -import us.shandian.giga.io.StoredFileHelper; +import org.schabi.newpipe.streams.io.StoredFileHelper; /** * SQLite helper to store finished {@link us.shandian.giga.get.FinishedMission}'s diff --git a/app/src/main/java/us/shandian/giga/io/SharpInputStream.java b/app/src/main/java/us/shandian/giga/io/SharpInputStream.java deleted file mode 100644 index 0d6320b53..000000000 --- a/app/src/main/java/us/shandian/giga/io/SharpInputStream.java +++ /dev/null @@ -1,61 +0,0 @@ -/* - * To change this license header, choose License Headers in Project Properties. - * To change this template file, choose Tools | Templates - * and open the template in the editor. - */ -package us.shandian.giga.io; - -import androidx.annotation.NonNull; - -import org.schabi.newpipe.streams.io.SharpStream; - -import java.io.IOException; -import java.io.InputStream; - -/** - * Wrapper for the classic {@link java.io.InputStream} - * - * @author kapodamy - */ -public class SharpInputStream extends InputStream { - - private final SharpStream base; - - public SharpInputStream(SharpStream base) throws IOException { - if (!base.canRead()) { - throw new IOException("The provided stream is not readable"); - } - this.base = base; - } - - @Override - public int read() throws IOException { - return base.read(); - } - - @Override - public int read(@NonNull byte[] bytes) throws IOException { - return base.read(bytes); - } - - @Override - public int read(@NonNull byte[] bytes, int i, int i1) throws IOException { - return base.read(bytes, i, i1); - } - - @Override - public long skip(long l) throws IOException { - return base.skip(l); - } - - @Override - public int available() { - long res = base.available(); - return res > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) res; - } - - @Override - public void close() { - base.close(); - } -} diff --git a/app/src/main/java/us/shandian/giga/io/StoredFileHelper.java b/app/src/main/java/us/shandian/giga/io/StoredFileHelper.java deleted file mode 100644 index ad64042f1..000000000 --- a/app/src/main/java/us/shandian/giga/io/StoredFileHelper.java +++ /dev/null @@ -1,386 +0,0 @@ -package us.shandian.giga.io; - -import android.annotation.TargetApi; -import android.content.ContentResolver; -import android.content.Context; -import android.content.Intent; -import android.net.Uri; -import android.os.Build; -import android.provider.DocumentsContract; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.documentfile.provider.DocumentFile; -import androidx.fragment.app.Fragment; - -import org.schabi.newpipe.streams.io.SharpStream; - -import java.io.File; -import java.io.IOException; -import java.io.Serializable; -import java.net.URI; - -public class StoredFileHelper implements Serializable { - private static final long serialVersionUID = 0L; - public static final String DEFAULT_MIME = "application/octet-stream"; - - private transient DocumentFile docFile; - private transient DocumentFile docTree; - private transient File ioFile; - private transient Context context; - - protected String source; - private String sourceTree; - - protected String tag; - - private String srcName; - private String srcType; - - public StoredFileHelper(@Nullable Uri parent, String filename, String mime, String tag) { - this.source = null;// this instance will be "invalid" see invalidate()/isInvalid() methods - - this.srcName = filename; - this.srcType = mime == null ? DEFAULT_MIME : mime; - if (parent != null) this.sourceTree = parent.toString(); - - this.tag = tag; - } - - @TargetApi(Build.VERSION_CODES.LOLLIPOP) - StoredFileHelper(@Nullable Context context, DocumentFile tree, String filename, String mime, boolean safe) throws IOException { - this.docTree = tree; - this.context = context; - - DocumentFile res; - - if (safe) { - // no conflicts (the filename is not in use) - res = this.docTree.createFile(mime, filename); - if (res == null) throw new IOException("Cannot create the file"); - } else { - res = createSAF(context, mime, filename); - } - - this.docFile = res; - - this.source = docFile.getUri().toString(); - this.sourceTree = docTree.getUri().toString(); - - this.srcName = this.docFile.getName(); - this.srcType = this.docFile.getType(); - } - - StoredFileHelper(File location, String filename, String mime) throws IOException { - this.ioFile = new File(location, filename); - - if (this.ioFile.exists()) { - if (!this.ioFile.isFile() && !this.ioFile.delete()) - throw new IOException("The filename is already in use by non-file entity and cannot overwrite it"); - } else { - if (!this.ioFile.createNewFile()) - throw new IOException("Cannot create the file"); - } - - this.source = Uri.fromFile(this.ioFile).toString(); - this.sourceTree = Uri.fromFile(location).toString(); - - this.srcName = ioFile.getName(); - this.srcType = mime; - } - - @TargetApi(Build.VERSION_CODES.KITKAT) - public StoredFileHelper(Context context, @Nullable Uri parent, @NonNull Uri path, String tag) throws IOException { - this.tag = tag; - this.source = path.toString(); - - if (path.getScheme() == null || path.getScheme().equalsIgnoreCase(ContentResolver.SCHEME_FILE)) { - this.ioFile = new File(URI.create(this.source)); - } else { - DocumentFile file = DocumentFile.fromSingleUri(context, path); - - if (file == null) throw new RuntimeException("SAF not available"); - - this.context = context; - - if (file.getName() == null) { - this.source = null; - return; - } else { - this.docFile = file; - takePermissionSAF(); - } - } - - if (parent != null) { - if (!ContentResolver.SCHEME_FILE.equals(parent.getScheme())) - this.docTree = DocumentFile.fromTreeUri(context, parent); - - this.sourceTree = parent.toString(); - } - - this.srcName = getName(); - this.srcType = getType(); - } - - - public static StoredFileHelper deserialize(@NonNull StoredFileHelper storage, Context context) throws IOException { - Uri treeUri = storage.sourceTree == null ? null : Uri.parse(storage.sourceTree); - - if (storage.isInvalid()) - return new StoredFileHelper(treeUri, storage.srcName, storage.srcType, storage.tag); - - StoredFileHelper instance = new StoredFileHelper(context, treeUri, Uri.parse(storage.source), storage.tag); - - // under SAF, if the target document is deleted, conserve the filename and mime - if (instance.srcName == null) instance.srcName = storage.srcName; - if (instance.srcType == null) instance.srcType = storage.srcType; - - return instance; - } - - public static void requestSafWithFileCreation(@NonNull Fragment who, int requestCode, String filename, String mime) { - // SAF notes: - // ACTION_OPEN_DOCUMENT Do not let you create the file, useful for overwrite files - // ACTION_CREATE_DOCUMENT No overwrite support, useless the file provider resolve the conflict - - Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT) - .addCategory(Intent.CATEGORY_OPENABLE) - .setType(mime) - .putExtra(Intent.EXTRA_TITLE, filename) - .addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION | StoredDirectoryHelper.PERMISSION_FLAGS) - .putExtra("android.content.extra.SHOW_ADVANCED", true);// hack, show all storage disks - - who.startActivityForResult(intent, requestCode); - } - - - public SharpStream getStream() throws IOException { - invalid(); - - if (docFile == null) - return new FileStream(ioFile); - else - return new FileStreamSAF(context.getContentResolver(), docFile.getUri()); - } - - /** - * Indicates whatever if is possible access using the {@code java.io} API - * - * @return {@code true} for Java I/O API, otherwise, {@code false} for Storage Access Framework - */ - public boolean isDirect() { - invalid(); - - return docFile == null; - } - - public boolean isInvalid() { - return source == null; - } - - public Uri getUri() { - invalid(); - - return docFile == null ? Uri.fromFile(ioFile) : docFile.getUri(); - } - - public Uri getParentUri() { - invalid(); - - return sourceTree == null ? null : Uri.parse(sourceTree); - } - - public void truncate() throws IOException { - invalid(); - - try (SharpStream fs = getStream()) { - fs.setLength(0); - } - } - - public boolean delete() { - if (source == null) return true; - if (docFile == null) return ioFile.delete(); - - - boolean res = docFile.delete(); - - try { - int flags = Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION; - context.getContentResolver().releasePersistableUriPermission(docFile.getUri(), flags); - } catch (Exception ex) { - // nothing to do - } - - return res; - } - - public long length() { - invalid(); - - return docFile == null ? ioFile.length() : docFile.length(); - } - - public boolean canWrite() { - if (source == null) return false; - return docFile == null ? ioFile.canWrite() : docFile.canWrite(); - } - - public String getName() { - if (source == null) - return srcName; - else if (docFile == null) - return ioFile.getName(); - - String name = docFile.getName(); - return name == null ? srcName : name; - } - - public String getType() { - if (source == null || docFile == null) - return srcType; - - String type = docFile.getType(); - return type == null ? srcType : type; - } - - public String getTag() { - return tag; - } - - public boolean existsAsFile() { - if (source == null) return false; - - // WARNING: DocumentFile.exists() and DocumentFile.isFile() methods are slow - boolean exists = docFile == null ? ioFile.exists() : docFile.exists(); - boolean isFile = docFile == null ? ioFile.isFile() : docFile.isFile();// ¿docFile.isVirtual() means is no-physical? - - return exists && isFile; - } - - public boolean create() { - invalid(); - boolean result; - - if (docFile == null) { - try { - result = ioFile.createNewFile(); - } catch (IOException e) { - return false; - } - } else if (docTree == null) { - result = false; - } else { - if (!docTree.canRead() || !docTree.canWrite()) return false; - try { - docFile = createSAF(context, srcType, srcName); - if (docFile.getName() == null) return false; - result = true; - } catch (IOException e) { - return false; - } - } - - if (result) { - source = (docFile == null ? Uri.fromFile(ioFile) : docFile.getUri()).toString(); - srcName = getName(); - srcType = getType(); - } - - return result; - } - - public void invalidate() { - if (source == null) return; - - srcName = getName(); - srcType = getType(); - - source = null; - - docTree = null; - docFile = null; - ioFile = null; - context = null; - } - - public boolean equals(StoredFileHelper storage) { - if (this == storage) return true; - - // note: do not compare tags, files can have the same parent folder - //if (stringMismatch(this.tag, storage.tag)) return false; - - if (stringMismatch(getLowerCase(this.sourceTree), getLowerCase(this.sourceTree))) - return false; - - if (this.isInvalid() || storage.isInvalid()) { - if (this.srcName == null || storage.srcName == null || this.srcType == null || storage.srcType == null) return false; - return this.srcName.equalsIgnoreCase(storage.srcName) && this.srcType.equalsIgnoreCase(storage.srcType); - } - - if (this.isDirect() != storage.isDirect()) return false; - - if (this.isDirect()) - return this.ioFile.getPath().equalsIgnoreCase(storage.ioFile.getPath()); - - return DocumentsContract.getDocumentId( - this.docFile.getUri() - ).equalsIgnoreCase(DocumentsContract.getDocumentId( - storage.docFile.getUri() - )); - } - - @NonNull - @Override - public String toString() { - if (source == null) - return "[Invalid state] name=" + srcName + " type=" + srcType + " tag=" + tag; - else - return "sourceFile=" + source + " treeSource=" + (sourceTree == null ? "" : sourceTree) + " tag=" + tag; - } - - - private void invalid() { - if (source == null) - throw new IllegalStateException("In invalid state"); - } - - private void takePermissionSAF() throws IOException { - try { - context.getContentResolver().takePersistableUriPermission(docFile.getUri(), StoredDirectoryHelper.PERMISSION_FLAGS); - } catch (Exception e) { - if (docFile.getName() == null) throw new IOException(e); - } - } - - @NonNull - private DocumentFile createSAF(@Nullable Context context, String mime, String filename) - throws IOException { - DocumentFile res = StoredDirectoryHelper.findFileSAFHelper(context, docTree, filename); - - if (res != null && res.exists() && res.isDirectory()) { - if (!res.delete()) - throw new IOException("Directory with the same name found but cannot delete"); - res = null; - } - - if (res == null) { - res = this.docTree.createFile(srcType == null ? DEFAULT_MIME : mime, filename); - if (res == null) throw new IOException("Cannot create the file"); - } - - return res; - } - - private String getLowerCase(String str) { - return str == null ? null : str.toLowerCase(); - } - - private boolean stringMismatch(String str1, String str2) { - if (str1 == null && str2 == null) return false; - if ((str1 == null) != (str2 == null)) return true; - - return !str1.equals(str2); - } -} diff --git a/app/src/main/java/us/shandian/giga/service/DownloadManager.java b/app/src/main/java/us/shandian/giga/service/DownloadManager.java index 8359fce9a..a2811e72e 100644 --- a/app/src/main/java/us/shandian/giga/service/DownloadManager.java +++ b/app/src/main/java/us/shandian/giga/service/DownloadManager.java @@ -19,8 +19,8 @@ import us.shandian.giga.get.DownloadMission; import us.shandian.giga.get.FinishedMission; import us.shandian.giga.get.Mission; import us.shandian.giga.get.sqlite.FinishedMissionStore; -import us.shandian.giga.io.StoredDirectoryHelper; -import us.shandian.giga.io.StoredFileHelper; +import org.schabi.newpipe.streams.io.StoredDirectoryHelper; +import org.schabi.newpipe.streams.io.StoredFileHelper; import us.shandian.giga.util.Utility; import static org.schabi.newpipe.BuildConfig.DEBUG; @@ -106,7 +106,8 @@ public class DownloadManager { } /** - * Loads finished missions from the data source + * Loads finished missions from the data source and forgets finished missions whose file does + * not exist anymore. */ private ArrayList loadFinishedMissions() { ArrayList finishedMissions = mFinishedMissionStore.loadFinishedMissions(); @@ -331,14 +332,29 @@ public class DownloadManager { } /** - * Get a finished mission by its path + * Get the index into {@link #mMissionsFinished} of a finished mission by its path, return + * {@code -1} if there is no such mission. This function also checks if the matched mission's + * file exists, and, if it does not, the related mission is forgotten about (like in {@link + * #loadFinishedMissions()}) and {@code -1} is returned. * - * @param storage where the file possible is stored + * @param storage where the file would be stored * @return the mission index or -1 if no such mission exists */ private int getFinishedMissionIndex(StoredFileHelper storage) { for (int i = 0; i < mMissionsFinished.size(); i++) { if (mMissionsFinished.get(i).storage.equals(storage)) { + // If the file does not exist the mission is not valid anymore. Also checking if + // length == 0 since the file picker may create an empty file before yielding it, + // but that does not mean the file really belonged to a previous mission. + if (!storage.existsAsFile() || storage.length() == 0) { + if (DEBUG) { + Log.d(TAG, "matched downloaded file removed: " + storage.getName()); + } + + mFinishedMissionStore.deleteMission(mMissionsFinished.get(i)); + mMissionsFinished.remove(i); + return -1; // finished mission whose associated file was removed + } return i; } } 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 568c3497a..52c28828d 100755 --- a/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java +++ b/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java @@ -47,8 +47,8 @@ import java.util.ArrayList; import us.shandian.giga.get.DownloadMission; import us.shandian.giga.get.MissionRecoveryInfo; -import us.shandian.giga.io.StoredDirectoryHelper; -import us.shandian.giga.io.StoredFileHelper; +import org.schabi.newpipe.streams.io.StoredDirectoryHelper; +import org.schabi.newpipe.streams.io.StoredFileHelper; import us.shandian.giga.postprocessing.Postprocessing; import us.shandian.giga.service.DownloadManager.NetworkState; 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 41a254b49..45ee290f6 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 @@ -61,7 +61,7 @@ import us.shandian.giga.get.DownloadMission; import us.shandian.giga.get.FinishedMission; import us.shandian.giga.get.Mission; import us.shandian.giga.get.MissionRecoveryInfo; -import us.shandian.giga.io.StoredFileHelper; +import org.schabi.newpipe.streams.io.StoredFileHelper; import us.shandian.giga.service.DownloadManager; import us.shandian.giga.service.DownloadManagerService; import us.shandian.giga.ui.common.Deleter; diff --git a/app/src/main/java/us/shandian/giga/ui/fragment/MissionsFragment.java b/app/src/main/java/us/shandian/giga/ui/fragment/MissionsFragment.java index c83eec819..793d147b5 100644 --- a/app/src/main/java/us/shandian/giga/ui/fragment/MissionsFragment.java +++ b/app/src/main/java/us/shandian/giga/ui/fragment/MissionsFragment.java @@ -29,14 +29,13 @@ import com.nononsenseapps.filepicker.Utils; import org.schabi.newpipe.R; import org.schabi.newpipe.settings.NewPipeSettings; +import org.schabi.newpipe.streams.io.StoredFileHelper; import org.schabi.newpipe.util.FilePickerActivityHelper; -import org.schabi.newpipe.util.ThemeHelper; import java.io.File; import java.io.IOException; import us.shandian.giga.get.DownloadMission; -import us.shandian.giga.io.StoredFileHelper; import us.shandian.giga.service.DownloadManager; import us.shandian.giga.service.DownloadManagerService; import us.shandian.giga.service.DownloadManagerService.DownloadManagerBinder; @@ -242,27 +241,21 @@ public class MissionsFragment extends Fragment { private void recoverMission(@NonNull DownloadMission mission) { unsafeMissionTarget = mission; + final Uri initialPath; if (NewPipeSettings.useStorageAccessFramework(mContext)) { - StoredFileHelper.requestSafWithFileCreation( - MissionsFragment.this, - REQUEST_DOWNLOAD_SAVE_AS, - mission.storage.getName(), - mission.storage.getType() - ); - + initialPath = null; } else { - File initialSavePath; - if (DownloadManager.TAG_VIDEO.equals(mission.storage.getType())) - initialSavePath = NewPipeSettings.getDir(Environment.DIRECTORY_MOVIES); - else + final File initialSavePath; + if (DownloadManager.TAG_AUDIO.equals(mission.storage.getType())) { initialSavePath = NewPipeSettings.getDir(Environment.DIRECTORY_MUSIC); - - initialSavePath = new File(initialSavePath, mission.storage.getName()); - startActivityForResult( - FilePickerActivityHelper.chooseFileToSave(mContext, initialSavePath.getAbsolutePath()), - REQUEST_DOWNLOAD_SAVE_AS - ); + } else { + initialSavePath = NewPipeSettings.getDir(Environment.DIRECTORY_MOVIES); + } + initialPath = Uri.parse(initialSavePath.getAbsolutePath()); } + + startActivityForResult(StoredFileHelper.getNewPicker(mContext, mission.storage.getName(), + mission.storage.getType(), initialPath), REQUEST_DOWNLOAD_SAVE_AS); } @Override diff --git a/app/src/main/java/us/shandian/giga/util/Utility.java b/app/src/main/java/us/shandian/giga/util/Utility.java index ab584f0e6..9e6787d5d 100644 --- a/app/src/main/java/us/shandian/giga/util/Utility.java +++ b/app/src/main/java/us/shandian/giga/util/Utility.java @@ -29,7 +29,7 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.Locale; -import us.shandian.giga.io.StoredFileHelper; +import org.schabi.newpipe.streams.io.StoredFileHelper; public class Utility { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4fb4019bd..4be0519c8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -362,6 +362,7 @@ Please wait… Copied to clipboard Please define a download folder later in settings + No download folder set yet, choose the default download folder now This permission is needed to\nopen in popup mode 1 item deleted. @@ -640,10 +641,12 @@ Start downloads Pause downloads Ask where to download - You will be asked where to save each download - You will be asked where to save each download.\nChoose SAF if you want to download to an external SD card - Use SAF - The \'Storage Access Framework\' allows downloads to an external SD card.\nSome devices are incompatible + You will be asked where to save each download.\nEnable the system folder picker (SAF) if you want to download to an external SD card + You will be asked where to save each download + Use system folder picker (SAF) + The \'Storage Access Framework\' allows downloads to an external SD card + The \'Storage Access Framework\' is not supported on Android KitKat and below + Starting from Android 10 only \'Storage Access Framework\' is supported Choose an instance App language System default diff --git a/app/src/main/res/xml/download_settings.xml b/app/src/main/res/xml/download_settings.xml index 1bc0e8404..0912f546f 100644 --- a/app/src/main/res/xml/download_settings.xml +++ b/app/src/main/res/xml/download_settings.xml @@ -3,16 +3,14 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:title="@string/settings_category_downloads_title"> - -