diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index f6c3ae65ae8..81d969427ac 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -3,9 +3,9 @@ NewPipe contribution guidelines ## Crash reporting -Report crashes through the automated crash report system of NewPipe. +Report crashes through the **automated crash report system** of NewPipe. This way all the data needed for debugging is included in your bugreport for GitHub. -You'll see exactly what is sent, be able to add your comments, and then send it. +You'll see *exactly* what is sent, be able to add **your comments**, and then send it. ## Issue reporting/feature requests @@ -25,22 +25,57 @@ You'll see exactly what is sent, be able to add your comments, and then send it. ## Code contribution -* If you want to help out with an existing bug report or feature request, leave a comment on that issue saying you want to try your hand at it. -* If there is no existing issue for what you want to work on, open a new one describing your changes. This gives the team and the community a chance to give feedback before you spend time on something that is already in development, should be done differently, or should be avoided completely. -* Stick to NewPipe's style conventions of [checkStyle](https://github.com/checkstyle/checkstyle). It runs each time you build the project. -* Do not bring non-free software (e.g. binary blobs) into the project. Make sure you do not introduce Google - libraries. +### Guidelines + +* Stick to NewPipe's *style conventions* of [checkStyle](https://github.com/checkstyle/checkstyle) and [ktlint](https://github.com/pinterest/ktlint). They run each time you build the project. * Stick to [F-Droid contribution guidelines](https://f-droid.org/wiki/page/Inclusion_Policy). -* Make changes on a separate branch with a meaningful name, not on the _master_ branch or the _dev_ branch. This is commonly known as *feature branch workflow*. You may then send your changes as a pull request (PR) on GitHub. -* Please test (compile and run) your code before submitting changes! Ideally, provide test feedback in the PR description. Untested code will **not** be merged! -* Make sure your PR is up-to-date with the rest of the code. Often, a simple click on "Update branch" will do the job, but if not, you must rebase the dev branch manually and resolve the problems on your own. You can find help [on the wiki](https://github.com/TeamNewPipe/NewPipe/wiki/How-to-merge-a-PR). That makes the maintainers' jobs way easier. -* Please show intention to maintain your features and code after you contribute a PR. Unmaintained code is a hassle for core developers. If you do not intend to maintain features you plan to contribute, please rethink your submission, or clearly state that in the PR description. +* In particular **do not bring non-free software** (e.g. binary blobs) into the project. Make sure you do not introduce any closed-source library from Google. + +### Before starting development + +* If you want to help out with an existing bug report or feature request, **leave a comment** on that issue saying you want to try your hand at it. +* If there is no existing issue for what you want to work on, **open a new one** describing the changes you are planning to introduce. This gives the team and the community a chance to give **feedback** before you spend time on something that is already in development, should be done differently, or should be avoided completely. +* Please show **intention to maintain your features** and code after you contribute a PR. Unmaintained code is a hassle for core developers. If you do not intend to maintain features you plan to contribute, please rethink your submission, or clearly state that in the PR description. +* Create PRs that cover only **one specific issue/solution/bug**. Do not create PRs that are huge monoliths and could have been split into multiple independent contributions. +* NewPipe uses [NewPipeExtractor](https://github.com/TeamNewPipe/NewPipeExtractor) to fetch data from services. If you need to change something there, you must test your changes in NewPipe. Telling NewPipe to use your extractor version can be accomplished by editing the `app/build.gradle` file: the comments under the "NewPipe libraries" section of `dependencies` will help you out. + +### Creating a Pull Request (PR) + +* Make changes on a **separate branch** with a meaningful name, not on the _master_ branch or the _dev_ branch. This is commonly known as *feature branch workflow*. You may then send your changes as a pull request (PR) on GitHub. +* Please **test** (compile and run) your code before submitting changes! Ideally, provide test feedback in the PR description. Untested code will **not** be merged! * Respond if someone requests changes or otherwise raises issues about your PRs. -* Send PRs that only cover one specific issue/solution/bug. Do not send PRs that are huge and consist of multiple independent solutions. * Try to figure out yourself why builds on our CI fail. +* Make sure your PR is **up-to-date** with the rest of the code. Often, a simple click on "Update branch" will do the job, but if not, you must *rebase* your branch on the `dev` branch manually and resolve the conflicts on your own. You can find help [on the wiki](https://github.com/TeamNewPipe/NewPipe/wiki/How-to-merge-a-PR). Doing this makes the maintainers' job way easier. + +## IDE setup & building the app + +### Basic setup + +NewPipe is developed using [Android Studio](https://developer.android.com/studio/). Learn more about how to install it and how it works in the [official documentation](https://developer.android.com/studio/intro). In particular, make sure you have accepted Android Studio's SDK licences. Once Android Studio is ready, setting up the NewPipe project is fairly simple: +- Clone the NewPipe repository with `git clone https://github.com/TeamNewPipe/NewPipe.git` (or use the link from your own fork, if you want to open a PR). +- Open the folder you just cloned with Android Studio. +- Build and run it just like you would do with any other app, with the green triangle in the top bar. + +You may find [SonarLint](https://www.sonarlint.org/intellij)'s **inspections** useful in helping you to write good code and prevent bugs. + +### checkStyle setup + +The [checkStyle](https://github.com/checkstyle/checkstyle) plugin verifies that Java code abides by the project style. It runs automatically each time you build the project. If you want to view errors directly in the editor, instead of having to skim through the build output, you can install an Android Studio plugin: +- Go to `File -> Settings -> Plugins`, search for `checkstyle` and install `CheckStyle-IDEA`. +- Go to `File -> Settings -> Tools -> Checkstyle`. +- Add NewPipe's configuration file by clicking the `+` in the right toolbar of the "Configuration File" list. +- Under the "Use a local Checkstyle file" bullet, click on `Browse` and pick the file named `checkstyle.xml` in the project's root folder. +- Enable "Store relative to project location" so that moving the directory around does not create issues. +- Insert a description in the top bar, then click `Next` and then `Finish`. +- Activate the configuration file you just added by enabling the checkbox on the left. +- Click `Ok` and you are done. + +### ktlint setup + +The [ktlint](https://github.com/pinterest/ktlint) plugin does the same job as checkStyle for Kotlin files. Installing the related plugin is as simple as going to `File -> Settings -> Plugins`, searching for `ktlint` and installing `Ktlint (unofficial)`. ## Communication * The #newpipe channel on Libera Chat (`ircs://irc.libera.chat:6697/newpipe`) has the core team and other developers in it. [Click here for webchat](https://web.libera.chat/#newpipe)! -* You can also use a Matrix account to join the NewPipe channel at [#newpipe:libera.chat](https://matrix.to/#/#newpipe:libera.chat). -* Post suggestions, changes, ideas etc. on GitHub or IRC. +* You can also use a Matrix account to join the NewPipe channel at [#newpipe:libera.chat](https://matrix.to/#/#newpipe:libera.chat). Some convenient clients, available both for phone and desktop, are listed at that link. +* You can post your suggestions, changes, ideas etc. on either GitHub or IRC. diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index e74a5a76116..ad9f1f82fa8 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -33,7 +33,7 @@ Oh no, a bug! It happens. Thanks for reporting an issue with NewPipe. To make it -### Actual behaviour +### Actual behavior diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md new file mode 100644 index 00000000000..5582fd407ce --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.md @@ -0,0 +1,24 @@ +--- +name: Question +about: Ask about anything NewPipe-related +labels: question +assignees: '' + +--- + + + + + +### Checklist + + +- [x] I checked, but didn't find any duplicates (open OR closed) of this issue in the repo. +- [ ] I have read the contribution guidelines given at https://github.com/TeamNewPipe/NewPipe/blob/HEAD/.github/CONTRIBUTING.md. + +#### What's your question(s)? + + +#### Additional context + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 845d57d908b..10e40af2acb 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -12,18 +12,23 @@ - create clones - take over the world +#### Before/After Screenshots/Screen Record + +- Before: +- After: + #### Fixes the following issue(s) - Fixes # #### Relies on the following changes - + - #### APK testing -On the website the APK can be found by going to the "Checks" tab below the title and then on "artifacts" on the right. +The APK can be found by going to the "Checks" tab below the title. On the left pane, click on "CI", scroll down to "artifacts" and click "app" to download the zip file which contains the debug APK of this PR. #### Due diligence - [ ] I read the [contribution guidelines](https://github.com/TeamNewPipe/NewPipe/blob/HEAD/.github/CONTRIBUTING.md). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 676e46333b7..8065d5e6a1e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,32 +43,37 @@ jobs: with: name: app path: app/build/outputs/apk/debug/*.apk - test-android: - runs-on: macos-latest - strategy: - matrix: - api-level: [21, 29] - steps: - - uses: actions/checkout@v2 - - name: set up JDK 8 - uses: actions/setup-java@v2 - with: - java-version: 8 - distribution: "adopt" +# Disabled until emulator works again. see https://github.com/TeamNewPipe/NewPipe/pull/6560 +# test-android: + # macos has hardware acceleration. See android-emulator-runner action +# runs-on: macos-latest +# strategy: +# matrix: + # api-level 19 is min sdk, but throws errors related to desugaring +# api-level: [21, 29] +# steps: +# - uses: actions/checkout@v2 +# +# - name: set up JDK 8 +# uses: actions/setup-java@v2 +# with: +# java-version: 8 +# distribution: "adopt" +# +# - name: Cache Gradle dependencies +# uses: actions/cache@v2 +# with: +# path: ~/.gradle/caches +# key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }} +# restore-keys: ${{ runner.os }}-gradle +# +# - name: Run android tests +# uses: reactivecircus/android-emulator-runner@v2 +# with: +# api-level: ${{ matrix.api-level }} +# script: ./gradlew connectedCheck - - name: Cache Gradle dependencies - uses: actions/cache@v2 - with: - path: ~/.gradle/caches - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }} - restore-keys: ${{ runner.os }}-gradle - - - name: Run android tests - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: ${{ matrix.api-level }} - script: ./gradlew connectedCheck # sonar: # runs-on: ubuntu-latest # steps: diff --git a/app/build.gradle b/app/build.gradle index 682e67362d9..0fb1437fd67 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -17,8 +17,8 @@ android { resValue "string", "app_name", "NewPipe" minSdkVersion 19 targetSdkVersion 29 - versionCode 971 - versionName "0.21.5" + versionCode 972 + versionName "0.21.6" multiDexEnabled true @@ -102,7 +102,7 @@ ext { checkstyleVersion = '8.38' androidxLifecycleVersion = '2.2.0' - androidxRoomVersion = '2.3.0-alpha03' + androidxRoomVersion = '2.3.0' icepickVersion = '3.2.0' exoPlayerVersion = '2.12.3' @@ -182,8 +182,11 @@ dependencies { /** NewPipe libraries **/ // You can use a local version by uncommenting a few lines in settings.gradle + // Or you can use a commit you pushed to GitHub by just replacing TeamNewPipe with your GitHub + // name and the commit hash with the commit hash of the (pushed) commit you want to test + // This works thanks to JitPack: https://jitpack.io/ implementation 'com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751' - implementation 'com.github.TeamNewPipe:NewPipeExtractor:v0.21.4' + implementation 'com.github.TeamNewPipe:NewPipeExtractor:v0.21.6' /** Checkstyle **/ checkstyle "com.puppycrawl.tools:checkstyle:${checkstyleVersion}" @@ -198,6 +201,7 @@ dependencies { implementation 'androidx.constraintlayout:constraintlayout:2.0.4' implementation 'androidx.core:core-ktx:1.3.2' implementation 'androidx.documentfile:documentfile:1.0.1' + implementation 'androidx.fragment:fragment-ktx:1.3.4' implementation "androidx.lifecycle:lifecycle-livedata:${androidxLifecycleVersion}" implementation "androidx.lifecycle:lifecycle-viewmodel:${androidxLifecycleVersion}" implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.0.0' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index eb15cddcea0..75479345d24 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"> @@ -232,11 +231,10 @@ - - + + - @@ -244,6 +242,15 @@ + + + + + + + + + diff --git a/app/src/main/java/com/google/android/material/appbar/FlingBehavior.java b/app/src/main/java/com/google/android/material/appbar/FlingBehavior.java index 743ff1ff20e..ca1bd79d2cf 100644 --- a/app/src/main/java/com/google/android/material/appbar/FlingBehavior.java +++ b/app/src/main/java/com/google/android/material/appbar/FlingBehavior.java @@ -63,8 +63,9 @@ public boolean onRequestChildRectangleOnScreen( return consumed == dy; } - public boolean onInterceptTouchEvent(final CoordinatorLayout parent, final AppBarLayout child, - final MotionEvent ev) { + public boolean onInterceptTouchEvent(@NonNull final CoordinatorLayout parent, + @NonNull final AppBarLayout child, + @NonNull final MotionEvent ev) { for (final Integer element : skipInterceptionOfElements) { final View view = child.findViewById(element); if (view != null) { diff --git a/app/src/main/java/org/schabi/newpipe/App.java b/app/src/main/java/org/schabi/newpipe/App.java index af118387caa..784be8d0b49 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.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 void onCreate() { 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/CheckForNewAppVersion.java b/app/src/main/java/org/schabi/newpipe/CheckForNewAppVersion.java index f84d986aab0..37ca0e400de 100644 --- a/app/src/main/java/org/schabi/newpipe/CheckForNewAppVersion.java +++ b/app/src/main/java/org/schabi/newpipe/CheckForNewAppVersion.java @@ -129,13 +129,8 @@ private static void compareAppVersionAndShowNotification(@NonNull final Applicat if (BuildConfig.VERSION_CODE < versionCode) { // A pending intent to open the apk location url in the browser. - final Intent viewIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(apkLocationUrl)); - - final Intent intent = new Intent(Intent.ACTION_CHOOSER); - intent.putExtra(Intent.EXTRA_INTENT, viewIntent); - intent.putExtra(Intent.EXTRA_TITLE, R.string.open_with); + final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(apkLocationUrl)); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - final PendingIntent pendingIntent = PendingIntent.getActivity(application, 0, intent, 0); diff --git a/app/src/main/java/org/schabi/newpipe/ExitActivity.java b/app/src/main/java/org/schabi/newpipe/ExitActivity.java index d457500aaf5..8da22db2d3a 100644 --- a/app/src/main/java/org/schabi/newpipe/ExitActivity.java +++ b/app/src/main/java/org/schabi/newpipe/ExitActivity.java @@ -6,6 +6,8 @@ import android.os.Build; import android.os.Bundle; +import org.schabi.newpipe.util.NavigationHelper; + /* * Copyright (C) Hans-Christoph Steiner 2016 * ExitActivity.java is part of NewPipe. @@ -48,6 +50,6 @@ protected void onCreate(final Bundle savedInstanceState) { finish(); } - System.exit(0); + NavigationHelper.restartApp(this); } } diff --git a/app/src/main/java/org/schabi/newpipe/MainActivity.java b/app/src/main/java/org/schabi/newpipe/MainActivity.java index 26ee261a46d..9bd28937660 100644 --- a/app/src/main/java/org/schabi/newpipe/MainActivity.java +++ b/app/src/main/java/org/schabi/newpipe/MainActivity.java @@ -603,6 +603,7 @@ public void onBackPressed() { public void onRequestPermissionsResult(final int requestCode, @NonNull final String[] permissions, @NonNull final int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); for (final int i : grantResults) { if (i == PackageManager.PERMISSION_DENIED) { return; diff --git a/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java b/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java index 988a5ed9806..8f773221840 100644 --- a/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java +++ b/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java @@ -51,4 +51,15 @@ public static void checkpoint() { throw new RuntimeException("Checkpoint was blocked from completing"); } } + + public static void close() { + if (databaseInstance != null) { + synchronized (NewPipeDatabase.class) { + if (databaseInstance != null) { + databaseInstance.close(); + databaseInstance = null; + } + } + } + } } diff --git a/app/src/main/java/org/schabi/newpipe/RouterActivity.java b/app/src/main/java/org/schabi/newpipe/RouterActivity.java index 0d70a7181dd..0c616508413 100644 --- a/app/src/main/java/org/schabi/newpipe/RouterActivity.java +++ b/app/src/main/java/org/schabi/newpipe/RouterActivity.java @@ -69,7 +69,7 @@ import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.PermissionHelper; -import org.schabi.newpipe.util.ShareUtils; +import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.util.ThemeHelper; import org.schabi.newpipe.util.urlfinder.UrlFinder; import org.schabi.newpipe.views.FocusOverlayView; @@ -107,6 +107,7 @@ public class RouterActivity extends AppCompatActivity { protected String currentUrl; private StreamingService currentService; private boolean selectionIsDownload = false; + private AlertDialog alertDialogChoice = null; @Override protected void onCreate(final Bundle savedInstanceState) { @@ -126,6 +127,15 @@ protected void onCreate(final Bundle savedInstanceState) { ? R.style.RouterActivityThemeLight : R.style.RouterActivityThemeDark); } + @Override + protected void onStop() { + super.onStop(); + // we need to dismiss the dialog before leaving the activity or we get leaks + if (alertDialogChoice != null) { + alertDialogChoice.dismiss(); + } + } + @Override protected void onSaveInstanceState(@NonNull final Bundle outState) { super.onSaveInstanceState(outState); @@ -333,7 +343,7 @@ private void showDialog(final List choices) { } }; - final AlertDialog alertDialog = new AlertDialog.Builder(themeWrapperContext) + alertDialogChoice = new AlertDialog.Builder(themeWrapperContext) .setTitle(R.string.preferred_open_action_share_menu_title) .setView(radioGroup) .setCancelable(true) @@ -347,12 +357,12 @@ private void showDialog(final List choices) { .create(); //noinspection CodeBlock2Expr - alertDialog.setOnShowListener(dialog -> { - setDialogButtonsState(alertDialog, radioGroup.getCheckedRadioButtonId() != -1); + alertDialogChoice.setOnShowListener(dialog -> { + setDialogButtonsState(alertDialogChoice, radioGroup.getCheckedRadioButtonId() != -1); }); radioGroup.setOnCheckedChangeListener((group, checkedId) -> - setDialogButtonsState(alertDialog, true)); + setDialogButtonsState(alertDialogChoice, true)); final View.OnClickListener radioButtonsClickListener = v -> { final int indexOfChild = radioGroup.indexOfChild(v); if (indexOfChild == -1) { @@ -402,10 +412,10 @@ private void showDialog(final List choices) { } selectedPreviously = selectedRadioPosition; - alertDialog.show(); + alertDialogChoice.show(); if (DeviceUtils.isTv(this)) { - FocusOverlayView.setupFocusObserver(alertDialog); + FocusOverlayView.setupFocusObserver(alertDialogChoice); } } @@ -590,6 +600,7 @@ private void openDownloadDialog() { public void onRequestPermissionsResult(final int requestCode, @NonNull final String[] permissions, @NonNull final int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); for (final int i : grantResults) { if (i == PackageManager.PERMISSION_DENIED) { finish(); diff --git a/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt b/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt index 2f015a049ec..0199f30d889 100644 --- a/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt +++ b/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt @@ -17,8 +17,8 @@ import org.schabi.newpipe.R import org.schabi.newpipe.databinding.ActivityAboutBinding import org.schabi.newpipe.databinding.FragmentAboutBinding import org.schabi.newpipe.util.Localization -import org.schabi.newpipe.util.ShareUtils import org.schabi.newpipe.util.ThemeHelper +import org.schabi.newpipe.util.external_communication.ShareUtils class AboutActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { diff --git a/app/src/main/java/org/schabi/newpipe/about/LicenseFragment.kt b/app/src/main/java/org/schabi/newpipe/about/LicenseFragment.kt index d72ecf8948c..ba0c04eb099 100644 --- a/app/src/main/java/org/schabi/newpipe/about/LicenseFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/about/LicenseFragment.kt @@ -1,10 +1,7 @@ package org.schabi.newpipe.about import android.os.Bundle -import android.view.ContextMenu -import android.view.ContextMenu.ContextMenuInfo import android.view.LayoutInflater -import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.core.os.bundleOf @@ -14,7 +11,6 @@ import org.schabi.newpipe.R import org.schabi.newpipe.about.LicenseFragmentHelper.showLicense import org.schabi.newpipe.databinding.FragmentLicensesBinding import org.schabi.newpipe.databinding.ItemSoftwareComponentBinding -import org.schabi.newpipe.util.ShareUtils import java.util.Arrays import java.util.Objects @@ -23,7 +19,6 @@ import java.util.Objects */ class LicenseFragment : Fragment() { private lateinit var softwareComponents: Array - private var componentForContextMenu: SoftwareComponent? = null private var activeLicense: License? = null private val compositeDisposable = CompositeDisposable() @@ -73,7 +68,7 @@ class LicenseFragment : Fragment() { root.setOnClickListener { activeLicense = component.license compositeDisposable.add( - showLicense(activity, component.license) + showLicense(activity, component) ) } binding.licensesSoftwareComponents.addView(root) @@ -87,30 +82,6 @@ class LicenseFragment : Fragment() { return binding.root } - override fun onCreateContextMenu(menu: ContextMenu, v: View, menuInfo: ContextMenuInfo?) { - val inflater = requireActivity().menuInflater - val component = v.tag as SoftwareComponent - menu.setHeaderTitle(component.name) - inflater.inflate(R.menu.software_component, menu) - super.onCreateContextMenu(menu, v, menuInfo) - componentForContextMenu = component - } - - override fun onContextItemSelected(item: MenuItem): Boolean { - // item.getMenuInfo() is null so we use the tag of the view - val component = componentForContextMenu ?: return false - when (item.itemId) { - R.id.menu_software_website -> { - ShareUtils.openUrlInBrowser(activity, component.link) - return true - } - R.id.menu_software_show_license -> compositeDisposable.add( - showLicense(activity, component.license) - ) - } - return false - } - override fun onSaveInstanceState(savedInstanceState: Bundle) { super.onSaveInstanceState(savedInstanceState) if (activeLicense != null) { diff --git a/app/src/main/java/org/schabi/newpipe/about/LicenseFragmentHelper.kt b/app/src/main/java/org/schabi/newpipe/about/LicenseFragmentHelper.kt index bdb3edabdc5..7617ef451ea 100644 --- a/app/src/main/java/org/schabi/newpipe/about/LicenseFragmentHelper.kt +++ b/app/src/main/java/org/schabi/newpipe/about/LicenseFragmentHelper.kt @@ -11,6 +11,7 @@ import io.reactivex.rxjava3.schedulers.Schedulers import org.schabi.newpipe.R import org.schabi.newpipe.util.Localization import org.schabi.newpipe.util.ThemeHelper +import org.schabi.newpipe.util.external_communication.ShareUtils import java.io.BufferedReader import java.io.IOException import java.io.InputStreamReader @@ -113,4 +114,34 @@ object LicenseFragmentHelper { } } } + @JvmStatic + fun showLicense(context: Context?, component: SoftwareComponent): Disposable { + return if (context == null) { + Disposable.empty() + } else { + Observable.fromCallable { getFormattedLicense(context, component.license) } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { formattedLicense: String -> + val webViewData = Base64.encodeToString( + formattedLicense + .toByteArray(StandardCharsets.UTF_8), + Base64.NO_PADDING + ) + val webView = WebView(context) + webView.loadData(webViewData, "text/html; charset=UTF-8", "base64") + val alert = AlertDialog.Builder(context) + alert.setTitle(component.license.name) + alert.setView(webView) + Localization.assureCorrectAppLanguage(context) + alert.setPositiveButton( + R.string.dismiss + ) { dialog, _ -> dialog.dismiss() } + alert.setNeutralButton(R.string.open_website_license) { _, _ -> + ShareUtils.openUrlInBrowser(context, component.link) + } + alert.show() + } + } + } } diff --git a/app/src/main/java/org/schabi/newpipe/database/Converters.java b/app/src/main/java/org/schabi/newpipe/database/Converters.java deleted file mode 100644 index c46b5f4275c..00000000000 --- a/app/src/main/java/org/schabi/newpipe/database/Converters.java +++ /dev/null @@ -1,64 +0,0 @@ -package org.schabi.newpipe.database; - -import androidx.room.TypeConverter; - -import org.schabi.newpipe.extractor.stream.StreamType; -import org.schabi.newpipe.local.subscription.FeedGroupIcon; - -import java.time.Instant; -import java.time.OffsetDateTime; -import java.time.ZoneOffset; - -public final class Converters { - private Converters() { } - - /** - * Convert a long value to a {@link OffsetDateTime}. - * - * @param value the long value - * @return the {@code OffsetDateTime} - */ - @TypeConverter - public static OffsetDateTime offsetDateTimeFromTimestamp(final Long value) { - return value == null ? null : OffsetDateTime.ofInstant(Instant.ofEpochMilli(value), - ZoneOffset.UTC); - } - - /** - * Convert a {@link OffsetDateTime} to a long value. - * - * @param offsetDateTime the {@code OffsetDateTime} - * @return the long value - */ - @TypeConverter - public static Long offsetDateTimeToTimestamp(final OffsetDateTime offsetDateTime) { - return offsetDateTime == null ? null : offsetDateTime.withOffsetSameInstant(ZoneOffset.UTC) - .toInstant().toEpochMilli(); - } - - @TypeConverter - public static StreamType streamTypeOf(final String value) { - return StreamType.valueOf(value); - } - - @TypeConverter - public static String stringOf(final StreamType streamType) { - return streamType.name(); - } - - @TypeConverter - public static Integer integerOf(final FeedGroupIcon feedGroupIcon) { - return feedGroupIcon.getId(); - } - - @TypeConverter - public static FeedGroupIcon feedGroupIconOf(final Integer id) { - for (final FeedGroupIcon icon : FeedGroupIcon.values()) { - if (icon.getId() == id) { - return icon; - } - } - - throw new IllegalArgumentException("There's no feed group icon with the id \"" + id + "\""); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/Converters.kt b/app/src/main/java/org/schabi/newpipe/database/Converters.kt new file mode 100644 index 00000000000..0eafcede1af --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/Converters.kt @@ -0,0 +1,52 @@ +package org.schabi.newpipe.database + +import androidx.room.TypeConverter +import org.schabi.newpipe.extractor.stream.StreamType +import org.schabi.newpipe.local.subscription.FeedGroupIcon +import java.time.Instant +import java.time.OffsetDateTime +import java.time.ZoneOffset + +object Converters { + /** + * Convert a long value to a [OffsetDateTime]. + * + * @param value the long value + * @return the `OffsetDateTime` + */ + @TypeConverter + fun offsetDateTimeFromTimestamp(value: Long?): OffsetDateTime? { + return value?.let { OffsetDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneOffset.UTC) } + } + + /** + * Convert a [OffsetDateTime] to a long value. + * + * @param offsetDateTime the `OffsetDateTime` + * @return the long value + */ + @TypeConverter + fun offsetDateTimeToTimestamp(offsetDateTime: OffsetDateTime?): Long? { + return offsetDateTime?.withOffsetSameInstant(ZoneOffset.UTC)?.toInstant()?.toEpochMilli() + } + + @TypeConverter + fun streamTypeOf(value: String): StreamType { + return StreamType.valueOf(value) + } + + @TypeConverter + fun stringOf(streamType: StreamType): String { + return streamType.name + } + + @TypeConverter + fun integerOf(feedGroupIcon: FeedGroupIcon): Int { + return feedGroupIcon.id + } + + @TypeConverter + fun feedGroupIconOf(id: Int): FeedGroupIcon { + return FeedGroupIcon.values().first { it.id == id } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedDAO.kt b/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedDAO.kt index f216ba1d8d0..689f1ead67d 100644 --- a/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedDAO.kt +++ b/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedDAO.kt @@ -9,7 +9,8 @@ import androidx.room.Update import io.reactivex.rxjava3.core.Flowable import org.schabi.newpipe.database.feed.model.FeedEntity import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity -import org.schabi.newpipe.database.stream.model.StreamEntity +import org.schabi.newpipe.database.stream.StreamWithState +import org.schabi.newpipe.database.stream.model.StreamStateEntity import org.schabi.newpipe.database.subscription.SubscriptionEntity import java.time.OffsetDateTime @@ -20,21 +21,34 @@ abstract class FeedDAO { @Query( """ - SELECT s.* FROM streams s + SELECT s.*, sst.progress_time + FROM streams s + + LEFT JOIN stream_state sst + ON s.uid = sst.stream_id + + LEFT JOIN stream_history sh + ON s.uid = sh.stream_id INNER JOIN feed f ON s.uid = f.stream_id ORDER BY s.upload_date IS NULL DESC, s.upload_date DESC, s.uploader ASC - LIMIT 500 """ ) - abstract fun getAllStreams(): Flowable> + abstract fun getAllStreams(): Flowable> @Query( """ - SELECT s.* FROM streams s + SELECT s.*, sst.progress_time + FROM streams s + + LEFT JOIN stream_state sst + ON s.uid = sst.stream_id + + LEFT JOIN stream_history sh + ON s.uid = sh.stream_id INNER JOIN feed f ON s.uid = f.stream_id @@ -42,16 +56,88 @@ abstract class FeedDAO { INNER JOIN feed_group_subscription_join fgs ON fgs.subscription_id = f.subscription_id - INNER JOIN feed_group fg - ON fg.uid = fgs.group_id + WHERE fgs.group_id = :groupId + + ORDER BY s.upload_date IS NULL DESC, s.upload_date DESC, s.uploader ASC + LIMIT 500 + """ + ) + abstract fun getAllStreamsForGroup(groupId: Long): Flowable> + + /** + * @see StreamStateEntity.isFinished() + * @see StreamStateEntity.PLAYBACK_FINISHED_END_MILLISECONDS + * @return all of the non-live, never-played and non-finished streams in the feed + * (all of the cited conditions must hold for a stream to be in the returned list) + */ + @Query( + """ + SELECT s.*, sst.progress_time + FROM streams s + + LEFT JOIN stream_state sst + ON s.uid = sst.stream_id + + LEFT JOIN stream_history sh + ON s.uid = sh.stream_id + + INNER JOIN feed f + ON s.uid = f.stream_id + + WHERE ( + sh.stream_id IS NULL + OR sst.stream_id IS NULL + OR sst.progress_time < s.duration * 1000 - ${StreamStateEntity.PLAYBACK_FINISHED_END_MILLISECONDS} + OR sst.progress_time < s.duration * 1000 * 3 / 4 + OR s.stream_type = 'LIVE_STREAM' + OR s.stream_type = 'AUDIO_LIVE_STREAM' + ) + + ORDER BY s.upload_date IS NULL DESC, s.upload_date DESC, s.uploader ASC + LIMIT 500 + """ + ) + abstract fun getLiveOrNotPlayedStreams(): Flowable> + + /** + * @see StreamStateEntity.isFinished() + * @see StreamStateEntity.PLAYBACK_FINISHED_END_MILLISECONDS + * @param groupId the group id to get streams of + * @return all of the non-live, never-played and non-finished streams for the given feed group + * (all of the cited conditions must hold for a stream to be in the returned list) + */ + @Query( + """ + SELECT s.*, sst.progress_time + FROM streams s + + LEFT JOIN stream_state sst + ON s.uid = sst.stream_id + + LEFT JOIN stream_history sh + ON s.uid = sh.stream_id + + INNER JOIN feed f + ON s.uid = f.stream_id + + INNER JOIN feed_group_subscription_join fgs + ON fgs.subscription_id = f.subscription_id WHERE fgs.group_id = :groupId + AND ( + sh.stream_id IS NULL + OR sst.stream_id IS NULL + OR sst.progress_time < s.duration * 1000 - ${StreamStateEntity.PLAYBACK_FINISHED_END_MILLISECONDS} + OR sst.progress_time < s.duration * 1000 * 3 / 4 + OR s.stream_type = 'LIVE_STREAM' + OR s.stream_type = 'AUDIO_LIVE_STREAM' + ) ORDER BY s.upload_date IS NULL DESC, s.upload_date DESC, s.uploader ASC LIMIT 500 """ ) - abstract fun getAllStreamsFromGroup(groupId: Long): Flowable> + abstract fun getLiveOrNotPlayedStreamsForGroup(groupId: Long): Flowable> @Query( """ diff --git a/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.java b/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.java index 535f2d2d05d..0a765ed4eec 100644 --- a/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.java +++ b/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.java @@ -21,7 +21,7 @@ import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_ID; import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_TABLE; import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_STREAM_ID_ALIAS; -import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_PROGRESS_TIME; +import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_PROGRESS_MILLIS; import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE; @Dao @@ -80,7 +80,7 @@ public Flowable> listByService(final int serviceId) { + " LEFT JOIN " + "(SELECT " + JOIN_STREAM_ID + " AS " + JOIN_STREAM_ID_ALIAS + ", " - + STREAM_PROGRESS_TIME + + STREAM_PROGRESS_MILLIS + " FROM " + STREAM_STATE_TABLE + " )" + " ON " + STREAM_ID + " = " + JOIN_STREAM_ID_ALIAS) public abstract Flowable> getStatistics(); diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.kt index 0d46f20bfb8..81409ecf057 100644 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.kt +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.kt @@ -12,8 +12,8 @@ data class PlaylistStreamEntry( @Embedded val streamEntity: StreamEntity, - @ColumnInfo(name = StreamStateEntity.STREAM_PROGRESS_TIME, defaultValue = "0") - val progressTime: Long, + @ColumnInfo(name = StreamStateEntity.STREAM_PROGRESS_MILLIS, defaultValue = "0") + val progressMillis: Long, @ColumnInfo(name = PlaylistStreamEntity.JOIN_STREAM_ID) val streamId: Long, diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistDAO.java b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistDAO.java index 078385e56d8..70aaa3b2dc9 100644 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistDAO.java +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistDAO.java @@ -14,26 +14,26 @@ import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_TABLE; @Dao -public abstract class PlaylistDAO implements BasicDAO { +public interface PlaylistDAO extends BasicDAO { @Override @Query("SELECT * FROM " + PLAYLIST_TABLE) - public abstract Flowable> getAll(); + Flowable> getAll(); @Override @Query("DELETE FROM " + PLAYLIST_TABLE) - public abstract int deleteAll(); + int deleteAll(); @Override - public Flowable> listByService(final int serviceId) { + default Flowable> listByService(final int serviceId) { throw new UnsupportedOperationException(); } @Query("SELECT * FROM " + PLAYLIST_TABLE + " WHERE " + PLAYLIST_ID + " = :playlistId") - public abstract Flowable> getPlaylist(long playlistId); + Flowable> getPlaylist(long playlistId); @Query("DELETE FROM " + PLAYLIST_TABLE + " WHERE " + PLAYLIST_ID + " = :playlistId") - public abstract int deletePlaylist(long playlistId); + int deletePlaylist(long playlistId); @Query("SELECT COUNT(*) FROM " + PLAYLIST_TABLE) - public abstract Flowable getCount(); + Flowable getCount(); } diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.java b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.java index a488f00fcbf..6bb84942817 100644 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.java +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.java @@ -17,31 +17,31 @@ import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_URL; @Dao -public abstract class PlaylistRemoteDAO implements BasicDAO { +public interface PlaylistRemoteDAO extends BasicDAO { @Override @Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE) - public abstract Flowable> getAll(); + Flowable> getAll(); @Override @Query("DELETE FROM " + REMOTE_PLAYLIST_TABLE) - public abstract int deleteAll(); + int deleteAll(); @Override @Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId") - public abstract Flowable> listByService(int serviceId); + Flowable> listByService(int serviceId); @Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE " + REMOTE_PLAYLIST_URL + " = :url AND " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId") - public abstract Flowable> getPlaylist(long serviceId, String url); + Flowable> getPlaylist(long serviceId, String url); @Query("SELECT " + REMOTE_PLAYLIST_ID + " FROM " + REMOTE_PLAYLIST_TABLE + " WHERE " + REMOTE_PLAYLIST_URL + " = :url " + "AND " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId") - abstract Long getPlaylistIdInternal(long serviceId, String url); + Long getPlaylistIdInternal(long serviceId, String url); @Transaction - public long upsert(final PlaylistRemoteEntity playlist) { + default long upsert(final PlaylistRemoteEntity playlist) { final Long playlistId = getPlaylistIdInternal(playlist.getServiceId(), playlist.getUrl()); if (playlistId == null) { @@ -55,5 +55,5 @@ public long upsert(final PlaylistRemoteEntity playlist) { @Query("DELETE FROM " + REMOTE_PLAYLIST_TABLE + " WHERE " + REMOTE_PLAYLIST_ID + " = :playlistId") - public abstract int deletePlaylist(long playlistId); + int deletePlaylist(long playlistId); } diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.java b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.java index 09da6aca0d4..f4a0a758085 100644 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.java +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.java @@ -25,32 +25,32 @@ import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_ID; import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_TABLE; import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_STREAM_ID_ALIAS; -import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_PROGRESS_TIME; +import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_PROGRESS_MILLIS; import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE; @Dao -public abstract class PlaylistStreamDAO implements BasicDAO { +public interface PlaylistStreamDAO extends BasicDAO { @Override @Query("SELECT * FROM " + PLAYLIST_STREAM_JOIN_TABLE) - public abstract Flowable> getAll(); + Flowable> getAll(); @Override @Query("DELETE FROM " + PLAYLIST_STREAM_JOIN_TABLE) - public abstract int deleteAll(); + int deleteAll(); @Override - public Flowable> listByService(final int serviceId) { + default Flowable> listByService(final int serviceId) { throw new UnsupportedOperationException(); } @Query("DELETE FROM " + PLAYLIST_STREAM_JOIN_TABLE + " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId") - public abstract void deleteBatch(long playlistId); + void deleteBatch(long playlistId); @Query("SELECT COALESCE(MAX(" + JOIN_INDEX + "), -1)" + " FROM " + PLAYLIST_STREAM_JOIN_TABLE + " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId") - public abstract Flowable getMaximumIndexOf(long playlistId); + Flowable getMaximumIndexOf(long playlistId); @Transaction @Query("SELECT * FROM " + STREAM_TABLE + " INNER JOIN " @@ -64,12 +64,12 @@ public Flowable> listByService(final int serviceId) { + " LEFT JOIN " + "(SELECT " + JOIN_STREAM_ID + " AS " + JOIN_STREAM_ID_ALIAS + ", " - + STREAM_PROGRESS_TIME + + STREAM_PROGRESS_MILLIS + " FROM " + STREAM_STATE_TABLE + " )" + " ON " + STREAM_ID + " = " + JOIN_STREAM_ID_ALIAS + " ORDER BY " + JOIN_INDEX + " ASC") - public abstract Flowable> getOrderedStreamsOf(long playlistId); + Flowable> getOrderedStreamsOf(long playlistId); @Transaction @Query("SELECT " + PLAYLIST_ID + ", " + PLAYLIST_NAME + ", " + PLAYLIST_THUMBNAIL_URL + ", " @@ -80,5 +80,5 @@ public Flowable> listByService(final int serviceId) { + " ON " + PLAYLIST_ID + " = " + JOIN_PLAYLIST_ID + " GROUP BY " + JOIN_PLAYLIST_ID + " ORDER BY " + PLAYLIST_NAME + " COLLATE NOCASE ASC") - public abstract Flowable> getPlaylistMetadata(); + Flowable> getPlaylistMetadata(); } diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.kt b/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.kt index eca12f58400..9a622f6437a 100644 --- a/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.kt +++ b/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.kt @@ -5,7 +5,7 @@ import androidx.room.Embedded import org.schabi.newpipe.database.LocalItem import org.schabi.newpipe.database.history.model.StreamHistoryEntity import org.schabi.newpipe.database.stream.model.StreamEntity -import org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_PROGRESS_TIME +import org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_PROGRESS_MILLIS import org.schabi.newpipe.extractor.stream.StreamInfoItem import java.time.OffsetDateTime @@ -13,8 +13,8 @@ class StreamStatisticsEntry( @Embedded val streamEntity: StreamEntity, - @ColumnInfo(name = STREAM_PROGRESS_TIME, defaultValue = "0") - val progressTime: Long, + @ColumnInfo(name = STREAM_PROGRESS_MILLIS, defaultValue = "0") + val progressMillis: Long, @ColumnInfo(name = StreamHistoryEntity.JOIN_STREAM_ID) val streamId: Long, diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/StreamWithState.kt b/app/src/main/java/org/schabi/newpipe/database/stream/StreamWithState.kt new file mode 100644 index 00000000000..abeabf888c9 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/stream/StreamWithState.kt @@ -0,0 +1,14 @@ +package org.schabi.newpipe.database.stream + +import androidx.room.ColumnInfo +import androidx.room.Embedded +import org.schabi.newpipe.database.stream.model.StreamEntity +import org.schabi.newpipe.database.stream.model.StreamStateEntity + +data class StreamWithState( + @Embedded + val stream: StreamEntity, + + @ColumnInfo(name = StreamStateEntity.STREAM_PROGRESS_MILLIS) + val stateProgressMillis: Long? +) diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.java b/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.java index a6b36e3ff90..06371248d62 100644 --- a/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.java +++ b/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.java @@ -17,31 +17,31 @@ import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE; @Dao -public abstract class StreamStateDAO implements BasicDAO { +public interface StreamStateDAO extends BasicDAO { @Override @Query("SELECT * FROM " + STREAM_STATE_TABLE) - public abstract Flowable> getAll(); + Flowable> getAll(); @Override @Query("DELETE FROM " + STREAM_STATE_TABLE) - public abstract int deleteAll(); + int deleteAll(); @Override - public Flowable> listByService(final int serviceId) { + default Flowable> listByService(final int serviceId) { throw new UnsupportedOperationException(); } @Query("SELECT * FROM " + STREAM_STATE_TABLE + " WHERE " + JOIN_STREAM_ID + " = :streamId") - public abstract Flowable> getState(long streamId); + Flowable> getState(long streamId); @Query("DELETE FROM " + STREAM_STATE_TABLE + " WHERE " + JOIN_STREAM_ID + " = :streamId") - public abstract int deleteState(long streamId); + int deleteState(long streamId); @Insert(onConflict = OnConflictStrategy.IGNORE) - abstract void silentInsertInternal(StreamStateEntity streamState); + void silentInsertInternal(StreamStateEntity streamState); @Transaction - public long upsert(final StreamStateEntity stream) { + default long upsert(final StreamStateEntity stream) { silentInsertInternal(stream); return update(stream); } diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamStateEntity.java b/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamStateEntity.java index 1ce834a8249..75766850ff5 100644 --- a/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamStateEntity.java +++ b/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamStateEntity.java @@ -5,7 +5,7 @@ import androidx.room.Entity; import androidx.room.ForeignKey; -import java.util.concurrent.TimeUnit; +import java.util.Objects; import static androidx.room.ForeignKey.CASCADE; import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_STREAM_ID; @@ -25,26 +25,31 @@ public class StreamStateEntity { // This additional field is required for the SQL query because 'stream_id' is used // for some other joins already public static final String JOIN_STREAM_ID_ALIAS = "stream_id_alias"; - public static final String STREAM_PROGRESS_TIME = "progress_time"; + public static final String STREAM_PROGRESS_MILLIS = "progress_time"; /** - * Playback state will not be saved, if playback time is less than this threshold. + * Playback state will not be saved, if playback time is less than this threshold (5000ms = 5s). */ - private static final int PLAYBACK_SAVE_THRESHOLD_START_SECONDS = 5; + private static final long PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS = 5000; + /** - * Playback state will not be saved, if time left is less than this threshold. + * Stream will be considered finished if the playback time left exceeds this threshold + * (60000ms = 60s). + * @see #isFinished(long) + * @see org.schabi.newpipe.database.feed.dao.FeedDAO#getLiveOrNotPlayedStreams() + * @see org.schabi.newpipe.database.feed.dao.FeedDAO#getLiveOrNotPlayedStreamsForGroup(long) */ - private static final int PLAYBACK_SAVE_THRESHOLD_END_SECONDS = 10; + public static final long PLAYBACK_FINISHED_END_MILLISECONDS = 60000; @ColumnInfo(name = JOIN_STREAM_ID) private long streamUid; - @ColumnInfo(name = STREAM_PROGRESS_TIME) - private long progressTime; + @ColumnInfo(name = STREAM_PROGRESS_MILLIS) + private long progressMillis; - public StreamStateEntity(final long streamUid, final long progressTime) { + public StreamStateEntity(final long streamUid, final long progressMillis) { this.streamUid = streamUid; - this.progressTime = progressTime; + this.progressMillis = progressMillis; } public long getStreamUid() { @@ -55,27 +60,53 @@ public void setStreamUid(final long streamUid) { this.streamUid = streamUid; } - public long getProgressTime() { - return progressTime; + public long getProgressMillis() { + return progressMillis; + } + + public void setProgressMillis(final long progressMillis) { + this.progressMillis = progressMillis; } - public void setProgressTime(final long progressTime) { - this.progressTime = progressTime; + /** + * The state will be considered valid, and thus be saved, if the progress is more than {@link + * #PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS} or at least 1/4 of the video length. + * @param durationInSeconds the duration of the stream connected with this state, in seconds + * @return whether this stream state entity should be saved or not + */ + public boolean isValid(final long durationInSeconds) { + return progressMillis > PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS + || progressMillis > durationInSeconds * 1000 / 4; } - public boolean isValid(final int durationInSeconds) { - final int seconds = (int) TimeUnit.MILLISECONDS.toSeconds(progressTime); - return seconds > PLAYBACK_SAVE_THRESHOLD_START_SECONDS - && seconds < durationInSeconds - PLAYBACK_SAVE_THRESHOLD_END_SECONDS; + /** + * The video will be considered as finished, if the time left is less than {@link + * #PLAYBACK_FINISHED_END_MILLISECONDS} and the progress is at least 3/4 of the video length. + * The state will be saved anyway, so that it can be shown under stream info items, but the + * player will not resume if a state is considered as finished. Finished streams are also the + * ones that can be filtered out in the feed fragment. + * @see org.schabi.newpipe.database.feed.dao.FeedDAO#getLiveOrNotPlayedStreams() + * @see org.schabi.newpipe.database.feed.dao.FeedDAO#getLiveOrNotPlayedStreamsForGroup(long) + * @param durationInSeconds the duration of the stream connected with this state, in seconds + * @return whether the stream is finished or not + */ + public boolean isFinished(final long durationInSeconds) { + return progressMillis >= durationInSeconds * 1000 - PLAYBACK_FINISHED_END_MILLISECONDS + && progressMillis >= durationInSeconds * 1000 * 3 / 4; } @Override public boolean equals(@Nullable final Object obj) { if (obj instanceof StreamStateEntity) { return ((StreamStateEntity) obj).streamUid == streamUid - && ((StreamStateEntity) obj).progressTime == progressTime; + && ((StreamStateEntity) obj).progressMillis == progressMillis; } else { return false; } } + + @Override + public int hashCode() { + return Objects.hash(streamUid, progressMillis); + } } 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 e7ae8d8790b..c055f8f2383 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.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.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 void setVideoStreams(final List videoStreams) { setVideoStreams(new StreamSizeWrapper<>(videoStreams, getContext())); } - /*////////////////////////////////////////////////////////////////////////// - // LifeCycle - //////////////////////////////////////////////////////////////////////////*/ - public void setVideoStreams(final StreamSizeWrapper wvs) { this.wrappedVideoStreams = wvs; } @@ -374,12 +376,16 @@ public void onSaveInstanceState(@NonNull final Bundle outState) { 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 void onActivityResult(final int requestCode, final int resultCode, final // 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 @@ private void showFailedDialog(@StringRes final int msg) { 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) { - Toast.makeText(context, getString(R.string.no_available_dir), - Toast.LENGTH_LONG).show(); + 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 (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() == R.id.audio_button) { + startActivityForResult(StoredDirectoryHelper.getPicker(context), + REQUEST_DOWNLOAD_PICK_AUDIO_FOLDER); + } else { + startActivityForResult(StoredDirectoryHelper.getPicker(context), + REQUEST_DOWNLOAD_PICK_VIDEO_FOLDER); } + return; + } + + if (askForSavePath) { + final Uri initialPath; if (NewPipeSettings.useStorageAccessFramework(context)) { - StoredFileHelper.requestSafWithFileCreation(this, REQUEST_DOWNLOAD_SAVE_AS, - filename, mime); + initialPath = null; } else { - File initialSavePath; + final 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); + 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 @@ private void checkSelectedDownload(final StoredDirectoryHelper mainStorage, 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 @@ private void checkSelectedDownload(final StoredDirectoryHelper mainStorage, 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 @@ private void checkSelectedDownload(final StoredDirectoryHelper mainStorage, 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/error/ErrorActivity.java b/app/src/main/java/org/schabi/newpipe/error/ErrorActivity.java index 106a86cfad3..c0d88c8ec83 100644 --- a/app/src/main/java/org/schabi/newpipe/error/ErrorActivity.java +++ b/app/src/main/java/org/schabi/newpipe/error/ErrorActivity.java @@ -27,7 +27,7 @@ import org.schabi.newpipe.R; import org.schabi.newpipe.databinding.ActivityErrorBinding; import org.schabi.newpipe.util.Localization; -import org.schabi.newpipe.util.ShareUtils; +import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.util.ThemeHelper; import java.time.LocalDateTime; @@ -195,7 +195,8 @@ public boolean onOptionsItemSelected(final MenuItem item) { onBackPressed(); return true; case R.id.menu_item_share_error: - ShareUtils.shareText(this, getString(R.string.error_report_title), buildJson()); + ShareUtils.shareText(getApplicationContext(), + getString(R.string.error_report_title), buildJson()); return true; default: return false; @@ -220,13 +221,10 @@ private void openPrivacyPolicyDialog(final Context context, final String action) + getString(R.string.app_name) + " " + BuildConfig.VERSION_NAME) .putExtra(Intent.EXTRA_TEXT, buildJson()); - if (i.resolveActivity(getPackageManager()) != null) { - ShareUtils.openIntentInApp(context, i); - } + ShareUtils.openIntentInApp(context, i, true); } else if (action.equals("GITHUB")) { // open the NewPipe issue page on GitHub ShareUtils.openUrlInBrowser(this, ERROR_GITHUB_ISSUE_URL, false); } - }) .setNegativeButton(R.string.decline, (dialog, which) -> { // do nothing diff --git a/app/src/main/java/org/schabi/newpipe/error/ErrorInfo.kt b/app/src/main/java/org/schabi/newpipe/error/ErrorInfo.kt index e1249bc8398..487e7c7fbf2 100644 --- a/app/src/main/java/org/schabi/newpipe/error/ErrorInfo.kt +++ b/app/src/main/java/org/schabi/newpipe/error/ErrorInfo.kt @@ -6,6 +6,7 @@ import kotlinx.android.parcel.Parcelize import org.schabi.newpipe.R import org.schabi.newpipe.extractor.Info import org.schabi.newpipe.extractor.NewPipe +import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException import org.schabi.newpipe.extractor.exceptions.ExtractionException @@ -95,6 +96,7 @@ class ErrorInfo( action: UserAction ): Int { return when { + throwable is AccountTerminatedException -> R.string.account_terminated throwable is ContentNotAvailableException -> R.string.content_not_available throwable != null && throwable.isNetworkRelated -> R.string.network_error throwable is ContentNotSupportedException -> R.string.content_not_supported diff --git a/app/src/main/java/org/schabi/newpipe/error/ErrorPanelHelper.kt b/app/src/main/java/org/schabi/newpipe/error/ErrorPanelHelper.kt index 49bcfa92672..e790c5fc517 100644 --- a/app/src/main/java/org/schabi/newpipe/error/ErrorPanelHelper.kt +++ b/app/src/main/java/org/schabi/newpipe/error/ErrorPanelHelper.kt @@ -13,6 +13,8 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.disposables.Disposable import org.schabi.newpipe.MainActivity import org.schabi.newpipe.R +import org.schabi.newpipe.extractor.NewPipe +import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException import org.schabi.newpipe.extractor.exceptions.AgeRestrictedContentException import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException @@ -22,9 +24,11 @@ import org.schabi.newpipe.extractor.exceptions.PrivateContentException import org.schabi.newpipe.extractor.exceptions.ReCaptchaException import org.schabi.newpipe.extractor.exceptions.SoundCloudGoPlusContentException import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentException +import org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty import org.schabi.newpipe.ktx.animate import org.schabi.newpipe.ktx.isInterruptedCaused import org.schabi.newpipe.ktx.isNetworkRelated +import org.schabi.newpipe.util.ServiceHelper import java.util.concurrent.TimeUnit class ErrorPanelHelper( @@ -35,6 +39,8 @@ class ErrorPanelHelper( private val context: Context = rootView.context!! private val errorPanelRoot: View = rootView.findViewById(R.id.error_panel) private val errorTextView: TextView = errorPanelRoot.findViewById(R.id.error_message_view) + private val errorServiceInfoTextView: TextView = errorPanelRoot.findViewById(R.id.error_message_service_info_view) + private val errorServiceExplenationTextView: TextView = errorPanelRoot.findViewById(R.id.error_message_service_explenation_view) private val errorButtonAction: Button = errorPanelRoot.findViewById(R.id.error_button_action) private val errorButtonRetry: Button = errorPanelRoot.findViewById(R.id.error_button_retry) @@ -70,13 +76,40 @@ class ErrorPanelHelper( errorButtonAction.setOnClickListener(null) } errorTextView.setText(R.string.recaptcha_request_toast) + // additional info is only provided by AccountTerminatedException + errorServiceInfoTextView.isVisible = false + errorServiceExplenationTextView.isVisible = false errorButtonRetry.isVisible = true + } else if (errorInfo.throwable is AccountTerminatedException) { + errorButtonRetry.isVisible = false + errorButtonAction.isVisible = false + errorTextView.setText(R.string.account_terminated) + if (!isNullOrEmpty((errorInfo.throwable as AccountTerminatedException).message)) { + errorServiceInfoTextView.setText( + context.resources.getString( + R.string.service_provides_reason, + NewPipe.getNameOfService(ServiceHelper.getSelectedServiceId(context)) + ) + ) + errorServiceExplenationTextView.setText( + (errorInfo.throwable as AccountTerminatedException).message + ) + errorServiceInfoTextView.isVisible = true + errorServiceExplenationTextView.isVisible = true + } else { + errorServiceInfoTextView.isVisible = false + errorServiceExplenationTextView.isVisible = false + } } else { errorButtonAction.setText(R.string.error_snackbar_action) errorButtonAction.setOnClickListener { ErrorActivity.reportError(context, errorInfo) } + // additional info is only provided by AccountTerminatedException + errorServiceInfoTextView.isVisible = false + errorServiceExplenationTextView.isVisible = false + // hide retry button by default, then show only if not unavailable/unsupported content errorButtonRetry.isVisible = false errorTextView.setText( diff --git a/app/src/main/java/org/schabi/newpipe/fragments/MainFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/MainFragment.java index 88d7e757e1b..7e0186e1cc3 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/MainFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/MainFragment.java @@ -130,7 +130,7 @@ public void onCreateOptionsMenu(@NonNull final Menu menu, Log.d(TAG, "onCreateOptionsMenu() called with: " + "menu = [" + menu + "], inflater = [" + inflater + "]"); } - inflater.inflate(R.menu.main_fragment_menu, menu); + inflater.inflate(R.menu.menu_main_fragment, menu); final ActionBar supportActionBar = activity.getSupportActionBar(); if (supportActionBar != null) { diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.java index 5f1cbc36583..92a571f377d 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.java @@ -23,15 +23,15 @@ import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.ShareUtils; -import org.schabi.newpipe.util.TextLinkifier; +import org.schabi.newpipe.util.external_communication.ShareUtils; +import org.schabi.newpipe.util.external_communication.TextLinkifier; import java.util.ArrayList; import java.util.Collections; import java.util.List; import icepick.State; -import io.reactivex.rxjava3.disposables.Disposable; +import io.reactivex.rxjava3.disposables.CompositeDisposable; import static android.text.TextUtils.isEmpty; import static org.schabi.newpipe.extractor.stream.StreamExtractor.NO_AGE_LIMIT; @@ -41,8 +41,7 @@ public class DescriptionFragment extends BaseFragment { @State StreamInfo streamInfo = null; - @Nullable - Disposable descriptionDisposable = null; + final CompositeDisposable descriptionDisposables = new CompositeDisposable(); FragmentDescriptionBinding binding; public DescriptionFragment() { @@ -67,10 +66,8 @@ public View onCreateView(@NonNull final LayoutInflater inflater, @Override public void onDestroy() { + descriptionDisposables.clear(); super.onDestroy(); - if (descriptionDisposable != null) { - descriptionDisposable.dispose(); - } } @@ -133,17 +130,17 @@ private void loadDescriptionContent() { final Description description = streamInfo.getDescription(); switch (description.getType()) { case Description.HTML: - descriptionDisposable = TextLinkifier.createLinksFromHtmlBlock(requireContext(), - description.getContent(), binding.detailDescriptionView, - HtmlCompat.FROM_HTML_MODE_LEGACY); + TextLinkifier.createLinksFromHtmlBlock(binding.detailDescriptionView, + description.getContent(), HtmlCompat.FROM_HTML_MODE_LEGACY, streamInfo, + descriptionDisposables); break; case Description.MARKDOWN: - descriptionDisposable = TextLinkifier.createLinksFromMarkdownText(requireContext(), - description.getContent(), binding.detailDescriptionView); + TextLinkifier.createLinksFromMarkdownText(binding.detailDescriptionView, + description.getContent(), streamInfo, descriptionDisposables); break; case Description.PLAIN_TEXT: default: - descriptionDisposable = TextLinkifier.createLinksFromPlainText(requireContext(), - description.getContent(), binding.detailDescriptionView); + TextLinkifier.createLinksFromPlainText(binding.detailDescriptionView, + description.getContent(), streamInfo, descriptionDisposables); break; } } @@ -198,8 +195,8 @@ private void addMetadataItem(final LayoutInflater inflater, }); if (linkifyContent) { - TextLinkifier.createLinksFromPlainText(requireContext(), - content, itemBinding.metadataContentView); + TextLinkifier.createLinksFromPlainText(itemBinding.metadataContentView, content, null, + descriptionDisposables); } else { itemBinding.metadataContentView.setText(content); } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java index 784a1c3be7d..170718fb247 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java @@ -91,12 +91,12 @@ import org.schabi.newpipe.util.DeviceUtils; import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.ImageDisplayConstants; -import org.schabi.newpipe.util.KoreUtil; +import org.schabi.newpipe.util.external_communication.KoreUtils; import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.PermissionHelper; -import org.schabi.newpipe.util.ShareUtils; +import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.util.ThemeHelper; import java.util.ArrayList; @@ -454,8 +454,8 @@ public void onClick(final View v) { break; case R.id.detail_controls_share: if (currentInfo != null) { - ShareUtils.shareText(requireContext(), - currentInfo.getName(), currentInfo.getUrl()); + ShareUtils.shareText(requireContext(), currentInfo.getName(), + currentInfo.getUrl(), currentInfo.getThumbnailUrl()); } break; case R.id.detail_controls_open_in_browser: @@ -472,7 +472,7 @@ public void onClick(final View v) { if (DEBUG) { Log.i(TAG, "Failed to start kore", e); } - KoreUtil.showInstallKoreDialog(requireContext()); + KoreUtils.showInstallKoreDialog(requireContext()); } } break; @@ -631,7 +631,7 @@ protected void initListeners() { binding.detailControlsShare.setOnClickListener(this); binding.detailControlsOpenInBrowser.setOnClickListener(this); binding.detailControlsPlayWithKodi.setOnClickListener(this); - binding.detailControlsPlayWithKodi.setVisibility(KoreUtil.shouldShowPlayWithKodi( + binding.detailControlsPlayWithKodi.setVisibility(KoreUtils.shouldShowPlayWithKodi( requireContext(), serviceId) ? View.VISIBLE : View.GONE); binding.overlayThumbnail.setOnClickListener(this); @@ -1546,8 +1546,8 @@ public void handleResult(@NonNull final StreamInfo info) { .getDefaultResolutionIndex(activity, sortedVideoStreams); updateProgressInfo(info); initThumbnailViews(info); - disposables.add(showMetaInfoInTextView(info.getMetaInfo(), binding.detailMetaInfoTextView, - binding.detailMetaInfoSeparator)); + showMetaInfoInTextView(info.getMetaInfo(), binding.detailMetaInfoTextView, + binding.detailMetaInfoSeparator, disposables); if (player == null || player.isStopped()) { updateOverlayData(info.getName(), info.getUploaderName(), info.getThumbnailUrl()); @@ -1669,7 +1669,7 @@ private void updateProgressInfo(@NonNull final StreamInfo info) { .onErrorComplete() .observeOn(AndroidSchedulers.mainThread()) .subscribe(state -> { - showPlaybackProgress(state.getProgressTime(), info.getDuration() * 1000); + showPlaybackProgress(state.getProgressMillis(), info.getDuration() * 1000); animate(binding.positionView, true, 500); animate(binding.detailPositionView, true, 500); }, e -> { diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java index 72855868559..45436ab6b1f 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java @@ -33,7 +33,7 @@ import org.schabi.newpipe.info_list.InfoItemDialog; import org.schabi.newpipe.info_list.InfoListAdapter; import org.schabi.newpipe.player.helper.PlayerHolder; -import org.schabi.newpipe.util.KoreUtil; +import org.schabi.newpipe.util.external_communication.KoreUtils; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.OnClickGesture; import org.schabi.newpipe.util.StateSaver; @@ -371,7 +371,7 @@ protected void showStreamDialog(final StreamInfoItem item) { )); } entries.add(StreamDialogEntry.open_in_browser); - if (KoreUtil.shouldShowPlayWithKodi(context, item.getServiceId())) { + if (KoreUtils.shouldShowPlayWithKodi(context, item.getServiceId())) { entries.add(StreamDialogEntry.play_with_kodi); } if (!isNullOrEmpty(item.getUploaderUrl())) { @@ -389,7 +389,8 @@ protected void showStreamDialog(final StreamInfoItem item) { //////////////////////////////////////////////////////////////////////////*/ @Override - public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + public void onCreateOptionsMenu(@NonNull final Menu menu, + @NonNull final MenuInflater inflater) { if (DEBUG) { Log.d(TAG, "onCreateOptionsMenu() called with: " + "menu = [" + menu + "], inflater = [" + inflater + "]"); diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java index e02e18a8636..bc67180214c 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java @@ -43,7 +43,7 @@ import org.schabi.newpipe.util.ImageDisplayConstants; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.ShareUtils; +import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.util.ThemeHelper; import java.util.ArrayList; @@ -164,7 +164,8 @@ protected void initListeners() { //////////////////////////////////////////////////////////////////////////*/ @Override - public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + public void onCreateOptionsMenu(@NonNull final Menu menu, + @NonNull final MenuInflater inflater) { super.onCreateOptionsMenu(menu, inflater); final ActionBar supportActionBar = activity.getSupportActionBar(); if (useAsFrontPage && supportActionBar != null) { @@ -203,7 +204,8 @@ public boolean onOptionsItemSelected(final MenuItem item) { break; case R.id.menu_item_share: if (currentInfo != null) { - ShareUtils.shareText(requireContext(), name, currentInfo.getOriginalUrl()); + ShareUtils.shareText(requireContext(), name, currentInfo.getOriginalUrl(), + currentInfo.getAvatarUrl()); } break; default: diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentsFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentsFragment.java index 35ab663a626..5d2cc4fdf3a 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentsFragment.java @@ -85,7 +85,8 @@ public void handleResult(@NonNull final CommentsInfo result) { public void setTitle(final String title) { } @Override - public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { } + public void onCreateOptionsMenu(@NonNull final Menu menu, + @NonNull final MenuInflater inflater) { } @Override protected boolean isGridLayout() { diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/KioskFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/KioskFragment.java index 882bb021dc0..f37f487bf52 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/KioskFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/KioskFragment.java @@ -131,7 +131,8 @@ public void onResume() { //////////////////////////////////////////////////////////////////////////*/ @Override - public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + public void onCreateOptionsMenu(@NonNull final Menu menu, + @NonNull final MenuInflater inflater) { super.onCreateOptionsMenu(menu, inflater); final ActionBar supportActionBar = activity.getSupportActionBar(); if (supportActionBar != null && useAsFrontPage) { diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java index 0e36d18c702..de96905dbc3 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java @@ -42,10 +42,10 @@ import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue; import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.ImageDisplayConstants; -import org.schabi.newpipe.util.KoreUtil; +import org.schabi.newpipe.util.external_communication.KoreUtils; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.ShareUtils; +import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.util.StreamDialogEntry; import java.util.ArrayList; @@ -162,7 +162,7 @@ protected void showStreamDialog(final StreamInfoItem item) { )); } entries.add(StreamDialogEntry.open_in_browser); - if (KoreUtil.shouldShowPlayWithKodi(context, item.getServiceId())) { + if (KoreUtils.shouldShowPlayWithKodi(context, item.getServiceId())) { entries.add(StreamDialogEntry.play_with_kodi); } @@ -181,7 +181,8 @@ protected void showStreamDialog(final StreamInfoItem item) { } @Override - public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + public void onCreateOptionsMenu(@NonNull final Menu menu, + @NonNull final MenuInflater inflater) { if (DEBUG) { Log.d(TAG, "onCreateOptionsMenu() called with: " + "menu = [" + menu + "], inflater = [" + inflater + "]"); @@ -251,7 +252,7 @@ public boolean onOptionsItemSelected(final MenuItem item) { ShareUtils.openUrlInBrowser(requireContext(), url); break; case R.id.menu_item_share: - ShareUtils.shareText(requireContext(), name, url); + ShareUtils.shareText(requireContext(), name, url, currentInfo.getThumbnailUrl()); break; case R.id.menu_item_bookmark: onBookmarkClicked(); diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java index 70fce1cb75b..478cf94f37e 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java @@ -227,6 +227,25 @@ public void onViewCreated(@NonNull final View rootView, final Bundle savedInstan initSearchListeners(); } + private void updateService() { + try { + service = NewPipe.getService(serviceId); + } catch (final Exception e) { + ErrorActivity.reportUiErrorInSnackbar(this, + "Getting service for id " + serviceId, e); + } + } + + @Override + public void onStart() { + if (DEBUG) { + Log.d(TAG, "onStart() called"); + } + super.onStart(); + + updateService(); + } + @Override public void onPause() { super.onPause(); @@ -250,13 +269,6 @@ public void onResume() { } super.onResume(); - try { - service = NewPipe.getService(serviceId); - } catch (final Exception e) { - ErrorActivity.reportUiErrorInSnackbar(this, - "Getting service for id " + serviceId, e); - } - if (suggestionDisposable == null || suggestionDisposable.isDisposed()) { initSuggestionObserver(); } @@ -278,8 +290,9 @@ public void onResume() { handleSearchSuggestion(); - disposables.add(showMetaInfoInTextView(metaInfo == null ? null : Arrays.asList(metaInfo), - searchBinding.searchMetaInfoTextView, searchBinding.searchMetaInfoSeparator)); + showMetaInfoInTextView(metaInfo == null ? null : Arrays.asList(metaInfo), + searchBinding.searchMetaInfoTextView, searchBinding.searchMetaInfoSeparator, + disposables); if (TextUtils.isEmpty(searchString) || wasSearchFocused) { showKeyboardSearch(); @@ -412,7 +425,8 @@ public void reloadContent() { //////////////////////////////////////////////////////////////////////////*/ @Override - public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + public void onCreateOptionsMenu(@NonNull final Menu menu, + @NonNull final MenuInflater inflater) { super.onCreateOptionsMenu(menu, inflater); final ActionBar supportActionBar = activity.getSupportActionBar(); @@ -426,6 +440,12 @@ public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { int itemId = 0; boolean isFirstItem = true; final Context c = getContext(); + + if (service == null) { + Log.w(TAG, "onCreateOptionsMenu() called with null service"); + updateService(); + } + for (final String filter : service.getSearchQHFactory().getAvailableContentFilter()) { if (filter.equals(YoutubeSearchQueryHandlerFactory.MUSIC_SONGS)) { final MenuItem musicItem = menu.add(2, @@ -841,7 +861,7 @@ private void search(final String theSearchString, infoListAdapter.clearStreamItemList(); hideSuggestionsPanel(); showMetaInfoInTextView(null, searchBinding.searchMetaInfoTextView, - searchBinding.searchMetaInfoSeparator); + searchBinding.searchMetaInfoSeparator, disposables); hideKeyboardSearch(); disposables.add(historyRecordManager.onSearched(serviceId, theSearchString) @@ -986,8 +1006,8 @@ public void handleResult(@NonNull final SearchInfo result) { // List cannot be bundled without creating some containers metaInfo = new MetaInfo[result.getMetaInfo().size()]; metaInfo = result.getMetaInfo().toArray(metaInfo); - disposables.add(showMetaInfoInTextView(result.getMetaInfo(), - searchBinding.searchMetaInfoTextView, searchBinding.searchMetaInfoSeparator)); + showMetaInfoInTextView(result.getMetaInfo(), searchBinding.searchMetaInfoTextView, + searchBinding.searchMetaInfoSeparator, disposables); handleSearchSuggestion(); diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsFragment.java index a66b7d56974..6532417c064 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsFragment.java @@ -140,7 +140,8 @@ public void setTitle(final String title) { } @Override - public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + public void onCreateOptionsMenu(@NonNull final Menu menu, + @NonNull final MenuInflater inflater) { } private void setInitialData(final StreamInfo info) { diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsMiniInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsMiniInfoItemHolder.java index ae7ddfd6350..629240dc6bf 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsMiniInfoItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsMiniInfoItemHolder.java @@ -24,7 +24,7 @@ import org.schabi.newpipe.util.ImageDisplayConstants; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.ShareUtils; +import org.schabi.newpipe.util.external_communication.ShareUtils; import java.util.regex.Matcher; import java.util.regex.Pattern; diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java index 227c11f91a5..98699eb95e7 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java @@ -66,7 +66,7 @@ public void updateFromItem(final InfoItem infoItem, itemProgressView.setVisibility(View.VISIBLE); itemProgressView.setMax((int) item.getDuration()); itemProgressView.setProgress((int) TimeUnit.MILLISECONDS - .toSeconds(state2.getProgressTime())); + .toSeconds(state2.getProgressMillis())); } else { itemProgressView.setVisibility(View.GONE); } @@ -121,10 +121,10 @@ public void updateState(final InfoItem infoItem, itemProgressView.setMax((int) item.getDuration()); if (itemProgressView.getVisibility() == View.VISIBLE) { itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS - .toSeconds(state.getProgressTime())); + .toSeconds(state.getProgressMillis())); } else { itemProgressView.setProgress((int) TimeUnit.MILLISECONDS - .toSeconds(state.getProgressTime())); + .toSeconds(state.getProgressMillis())); ViewUtils.animate(itemProgressView, true, 500); } } else if (itemProgressView.getVisibility() == View.VISIBLE) { diff --git a/app/src/main/java/org/schabi/newpipe/local/BaseLocalListFragment.java b/app/src/main/java/org/schabi/newpipe/local/BaseLocalListFragment.java index 78fb200298c..32a1d414e19 100644 --- a/app/src/main/java/org/schabi/newpipe/local/BaseLocalListFragment.java +++ b/app/src/main/java/org/schabi/newpipe/local/BaseLocalListFragment.java @@ -9,6 +9,7 @@ import android.view.MenuInflater; import android.view.View; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; import androidx.fragment.app.Fragment; @@ -145,7 +146,8 @@ protected void initListeners() { //////////////////////////////////////////////////////////////////////////*/ @Override - public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + public void onCreateOptionsMenu(@NonNull final Menu menu, + @NonNull final MenuInflater inflater) { super.onCreateOptionsMenu(menu, inflater); if (DEBUG) { Log.d(TAG, "onCreateOptionsMenu() called with: " diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedDatabaseManager.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedDatabaseManager.kt index 9a4832c81f5..ff7c2848e6a 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/FeedDatabaseManager.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedDatabaseManager.kt @@ -12,6 +12,7 @@ import org.schabi.newpipe.NewPipeDatabase import org.schabi.newpipe.database.feed.model.FeedEntity import org.schabi.newpipe.database.feed.model.FeedGroupEntity import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity +import org.schabi.newpipe.database.stream.StreamWithState import org.schabi.newpipe.database.stream.model.StreamEntity import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.extractor.stream.StreamType @@ -38,16 +39,19 @@ class FeedDatabaseManager(context: Context) { fun database() = database - fun asStreamItems(groupId: Long = FeedGroupEntity.GROUP_ALL_ID): Flowable> { - val streams = when (groupId) { - FeedGroupEntity.GROUP_ALL_ID -> feedTable.getAllStreams() - else -> feedTable.getAllStreamsFromGroup(groupId) - } - - return streams.map { - val items = ArrayList(it.size) - it.mapTo(items) { stream -> stream.toStreamInfoItem() } - return@map items + fun getStreams( + groupId: Long = FeedGroupEntity.GROUP_ALL_ID, + getPlayedStreams: Boolean = true + ): Flowable> { + return when (groupId) { + FeedGroupEntity.GROUP_ALL_ID -> { + if (getPlayedStreams) feedTable.getAllStreams() + else feedTable.getLiveOrNotPlayedStreams() + } + else -> { + if (getPlayedStreams) feedTable.getAllStreamsForGroup(groupId) + else feedTable.getLiveOrNotPlayedStreamsForGroup(groupId) + } } } @@ -60,8 +64,10 @@ class FeedDatabaseManager(context: Context) { } } - fun outdatedSubscriptionsForGroup(groupId: Long = FeedGroupEntity.GROUP_ALL_ID, outdatedThreshold: OffsetDateTime) = - feedTable.getAllOutdatedForGroup(groupId, outdatedThreshold) + fun outdatedSubscriptionsForGroup( + groupId: Long = FeedGroupEntity.GROUP_ALL_ID, + outdatedThreshold: OffsetDateTime + ) = feedTable.getAllOutdatedForGroup(groupId, outdatedThreshold) fun markAsOutdated(subscriptionId: Long) = feedTable .setLastUpdatedForSubscription(FeedLastUpdatedEntity(subscriptionId, null)) @@ -93,10 +99,7 @@ class FeedDatabaseManager(context: Context) { } feedTable.setLastUpdatedForSubscription( - FeedLastUpdatedEntity( - subscriptionId, - OffsetDateTime.now(ZoneOffset.UTC) - ) + FeedLastUpdatedEntity(subscriptionId, OffsetDateTime.now(ZoneOffset.UTC)) ) } @@ -108,7 +111,12 @@ class FeedDatabaseManager(context: Context) { fun clear() { feedTable.deleteAll() val deletedOrphans = streamTable.deleteOrphans() - if (DEBUG) Log.d(this::class.java.simpleName, "clear() → streamTable.deleteOrphans() → $deletedOrphans") + if (DEBUG) { + Log.d( + this::class.java.simpleName, + "clear() → streamTable.deleteOrphans() → $deletedOrphans" + ) + } } // ///////////////////////////////////////////////////////////////////////// @@ -122,7 +130,8 @@ class FeedDatabaseManager(context: Context) { } fun updateSubscriptionsForGroup(groupId: Long, subscriptionIds: List): Completable { - return Completable.fromCallable { feedGroupTable.updateSubscriptionsForGroup(groupId, subscriptionIds) } + return Completable + .fromCallable { feedGroupTable.updateSubscriptionsForGroup(groupId, subscriptionIds) } .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) } diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt index 1df9991447a..4c1bb073242 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt @@ -19,7 +19,11 @@ package org.schabi.newpipe.local.feed +import android.annotation.SuppressLint +import android.app.Activity import android.content.Intent +import android.content.SharedPreferences +import android.content.res.Configuration import android.os.Bundle import android.os.Parcelable import android.view.LayoutInflater @@ -28,41 +32,74 @@ import android.view.MenuInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup +import androidx.annotation.Nullable import androidx.appcompat.app.AlertDialog +import androidx.appcompat.content.res.AppCompatResources import androidx.core.content.edit import androidx.core.os.bundleOf import androidx.core.view.isVisible import androidx.lifecycle.ViewModelProvider import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.GridLayoutManager +import com.xwray.groupie.GroupAdapter +import com.xwray.groupie.GroupieViewHolder +import com.xwray.groupie.Item +import com.xwray.groupie.OnItemClickListener +import com.xwray.groupie.OnItemLongClickListener import icepick.State +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.schedulers.Schedulers +import org.schabi.newpipe.NewPipeDatabase import org.schabi.newpipe.R import org.schabi.newpipe.database.feed.model.FeedGroupEntity +import org.schabi.newpipe.database.subscription.SubscriptionEntity import org.schabi.newpipe.databinding.FragmentFeedBinding import org.schabi.newpipe.error.ErrorInfo import org.schabi.newpipe.error.UserAction -import org.schabi.newpipe.fragments.list.BaseListFragment +import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException +import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.extractor.stream.StreamType +import org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty +import org.schabi.newpipe.fragments.BaseStateFragment +import org.schabi.newpipe.info_list.InfoItemDialog import org.schabi.newpipe.ktx.animate import org.schabi.newpipe.ktx.animateHideRecyclerViewAllowingScrolling +import org.schabi.newpipe.local.feed.item.StreamItem import org.schabi.newpipe.local.feed.service.FeedLoadService +import org.schabi.newpipe.local.subscription.SubscriptionManager +import org.schabi.newpipe.player.helper.PlayerHolder import org.schabi.newpipe.util.Localization +import org.schabi.newpipe.util.NavigationHelper +import org.schabi.newpipe.util.StreamDialogEntry import java.time.OffsetDateTime +import java.util.ArrayList +import kotlin.math.floor +import kotlin.math.max -class FeedFragment : BaseListFragment() { +class FeedFragment : BaseStateFragment() { private var _feedBinding: FragmentFeedBinding? = null private val feedBinding get() = _feedBinding!! + private val disposables = CompositeDisposable() + private lateinit var viewModel: FeedViewModel - @State - @JvmField - var listState: Parcelable? = null + @State @JvmField var listState: Parcelable? = null private var groupId = FeedGroupEntity.GROUP_ALL_ID private var groupName = "" private var oldestSubscriptionUpdate: OffsetDateTime? = null + private lateinit var groupAdapter: GroupAdapter + @State @JvmField var showPlayedItems: Boolean = true + + private var onSettingsChangeListener: SharedPreferences.OnSharedPreferenceChangeListener? = null + private var updateListViewModeOnResume = false + init { setHasOptionsMenu(true) - setUseDefaultStateSaving(false) } override fun onCreate(savedInstanceState: Bundle?) { @@ -71,6 +108,14 @@ class FeedFragment : BaseListFragment() { groupId = arguments?.getLong(KEY_GROUP_ID, FeedGroupEntity.GROUP_ALL_ID) ?: FeedGroupEntity.GROUP_ALL_ID groupName = arguments?.getString(KEY_GROUP_NAME) ?: "" + + onSettingsChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + if (key.equals(getString(R.string.list_view_mode_key))) { + updateListViewModeOnResume = true + } + } + PreferenceManager.getDefaultSharedPreferences(activity) + .registerOnSharedPreferenceChangeListener(onSettingsChangeListener) } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { @@ -82,8 +127,17 @@ class FeedFragment : BaseListFragment() { _feedBinding = FragmentFeedBinding.bind(rootView) super.onViewCreated(rootView, savedInstanceState) - viewModel = ViewModelProvider(this, FeedViewModel.Factory(requireContext(), groupId)).get(FeedViewModel::class.java) - viewModel.stateLiveData.observe(viewLifecycleOwner) { it?.let(::handleResult) } + val factory = FeedViewModel.Factory(requireContext(), groupId, showPlayedItems) + viewModel = ViewModelProvider(this, factory).get(FeedViewModel::class.java) + viewModel.stateLiveData.observe(viewLifecycleOwner, { it?.let(::handleResult) }) + + groupAdapter = GroupAdapter().apply { + setOnItemClickListener(listenerStreamItem) + setOnItemLongClickListener(listenerStreamItem) + } + + feedBinding.itemsList.adapter = groupAdapter + setupListViewMode() } override fun onPause() { @@ -94,6 +148,23 @@ class FeedFragment : BaseListFragment() { override fun onResume() { super.onResume() updateRelativeTimeViews() + + if (updateListViewModeOnResume) { + updateListViewModeOnResume = false + + setupListViewMode() + if (viewModel.stateLiveData.value != null) { + handleResult(viewModel.stateLiveData.value!!) + } + } + } + + fun setupListViewMode() { + // does everything needed to setup the layouts for grid or list modes + groupAdapter.spanCount = if (shouldUseGridLayout()) getGridSpanCount() else 1 + feedBinding.itemsList.layoutManager = GridLayoutManager(requireContext(), groupAdapter.spanCount).apply { + spanSizeLookup = groupAdapter.spanSizeLookup + } } override fun setUserVisibleHint(isVisibleToUser: Boolean) { @@ -116,21 +187,21 @@ class FeedFragment : BaseListFragment() { override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { super.onCreateOptionsMenu(menu, inflater) + + activity.supportActionBar?.setDisplayShowTitleEnabled(true) activity.supportActionBar?.setTitle(R.string.fragment_feed_title) activity.supportActionBar?.subtitle = groupName inflater.inflate(R.menu.menu_feed_fragment, menu) - - if (useAsFrontPage) { - menu.findItem(R.id.menu_item_feed_help).setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER) - } + updateTogglePlayedItemsButton(menu.findItem(R.id.menu_item_feed_toggle_played_items)) } override fun onOptionsItemSelected(item: MenuItem): Boolean { if (item.itemId == R.id.menu_item_feed_help) { val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) - val usingDedicatedMethod = sharedPreferences.getBoolean(getString(R.string.feed_use_dedicated_fetch_method_key), false) + val usingDedicatedMethod = sharedPreferences + .getBoolean(getString(R.string.feed_use_dedicated_fetch_method_key), false) val enableDisableButtonText = when { usingDedicatedMethod -> R.string.feed_use_dedicated_fetch_method_disable_button else -> R.string.feed_use_dedicated_fetch_method_enable_button @@ -147,6 +218,10 @@ class FeedFragment : BaseListFragment() { .create() .show() return true + } else if (item.itemId == R.id.menu_item_feed_toggle_played_items) { + showPlayedItems = !item.isChecked + updateTogglePlayedItemsButton(item) + viewModel.togglePlayedItems(showPlayedItems) } return super.onOptionsItemSelected(item) @@ -158,18 +233,34 @@ class FeedFragment : BaseListFragment() { } override fun onDestroy() { + disposables.dispose() + if (onSettingsChangeListener != null) { + PreferenceManager.getDefaultSharedPreferences(activity) + .unregisterOnSharedPreferenceChangeListener(onSettingsChangeListener) + onSettingsChangeListener = null + } + super.onDestroy() activity?.supportActionBar?.subtitle = null } override fun onDestroyView() { + feedBinding.itemsList.adapter = null _feedBinding = null super.onDestroyView() } - // ///////////////////////////////////////////////////////////////////////// + private fun updateTogglePlayedItemsButton(menuItem: MenuItem) { + menuItem.isChecked = showPlayedItems + menuItem.icon = AppCompatResources.getDrawable( + requireContext(), + if (showPlayedItems) R.drawable.ic_visibility_on else R.drawable.ic_visibility_off + ) + } + + // ////////////////////////////////////////////////////////////////////////// // Handling - // ///////////////////////////////////////////////////////////////////////// + // ////////////////////////////////////////////////////////////////////////// override fun showLoading() { super.showLoading() @@ -181,6 +272,7 @@ class FeedFragment : BaseListFragment() { override fun hideLoading() { super.hideLoading() + feedBinding.itemsList.animate(true, 0) feedBinding.refreshRootView.animate(true, 200) feedBinding.loadingProgressText.animate(false, 0) feedBinding.swipeRefreshLayout.isRefreshing = false @@ -206,7 +298,6 @@ class FeedFragment : BaseListFragment() { override fun handleError() { super.handleError() - infoListAdapter.clearStreamItemList() feedBinding.itemsList.animateHideRecyclerViewAllowingScrolling() feedBinding.refreshRootView.animate(false, 0) feedBinding.loadingProgressText.animate(false, 0) @@ -234,24 +325,96 @@ class FeedFragment : BaseListFragment() { feedBinding.loadingProgressBar.max = progressState.maxProgress } + private fun showStreamDialog(item: StreamInfoItem) { + val context = context + val activity: Activity? = getActivity() + if (context == null || context.resources == null || activity == null) return + + val entries = ArrayList() + if (PlayerHolder.getType() != null) { + entries.add(StreamDialogEntry.enqueue) + } + if (item.streamType == StreamType.AUDIO_STREAM) { + entries.addAll( + listOf( + StreamDialogEntry.start_here_on_background, + StreamDialogEntry.append_playlist, + StreamDialogEntry.share, + StreamDialogEntry.open_in_browser + ) + ) + } else { + entries.addAll( + listOf( + StreamDialogEntry.start_here_on_background, + StreamDialogEntry.start_here_on_popup, + StreamDialogEntry.append_playlist, + StreamDialogEntry.share, + StreamDialogEntry.open_in_browser + ) + ) + } + + StreamDialogEntry.setEnabledEntries(entries) + InfoItemDialog(activity, item, StreamDialogEntry.getCommands(context)) { _, which -> + StreamDialogEntry.clickOn(which, this, item) + }.show() + } + + private val listenerStreamItem = object : OnItemClickListener, OnItemLongClickListener { + override fun onItemClick(item: Item<*>, view: View) { + if (item is StreamItem) { + val stream = item.streamWithState.stream + NavigationHelper.openVideoDetailFragment( + requireContext(), fm, + stream.serviceId, stream.url, stream.title, null, false + ) + } + } + + override fun onItemLongClick(item: Item<*>, view: View): Boolean { + if (item is StreamItem) { + showStreamDialog(item.streamWithState.stream.toStreamInfoItem()) + return true + } + return false + } + } + + @SuppressLint("StringFormatMatches") private fun handleLoadedState(loadedState: FeedState.LoadedState) { - infoListAdapter.setInfoItemList(loadedState.items) + + val itemVersion = if (shouldUseGridLayout()) { + StreamItem.ItemVersion.GRID + } else { + StreamItem.ItemVersion.NORMAL + } + loadedState.items.forEach { it.itemVersion = itemVersion } + + groupAdapter.updateAsync(loadedState.items, false, null) + listState?.run { feedBinding.itemsList.layoutManager?.onRestoreInstanceState(listState) listState = null } - oldestSubscriptionUpdate = loadedState.oldestUpdate - - val loadedCount = loadedState.notLoadedCount > 0 - feedBinding.refreshSubtitleText.isVisible = loadedCount - if (loadedCount) { + val feedsNotLoaded = loadedState.notLoadedCount > 0 + feedBinding.refreshSubtitleText.isVisible = feedsNotLoaded + if (feedsNotLoaded) { feedBinding.refreshSubtitleText.text = getString( R.string.feed_subscription_not_loaded_count, loadedState.notLoadedCount ) } + if (oldestSubscriptionUpdate != loadedState.oldestUpdate || + (oldestSubscriptionUpdate == null && loadedState.oldestUpdate == null) + ) { + // ignore errors if they have already been handled for the current update + handleItemsErrors(loadedState.itemsErrors) + } + oldestSubscriptionUpdate = loadedState.oldestUpdate + if (loadedState.items.isEmpty()) { showEmptyState() } else { @@ -269,9 +432,78 @@ class FeedFragment : BaseListFragment() { } } + private fun handleItemsErrors(errors: List) { + errors.forEachIndexed { i, t -> + if (t is FeedLoadService.RequestException && + t.cause is ContentNotAvailableException + ) { + Single.fromCallable { + NewPipeDatabase.getInstance(requireContext()).subscriptionDAO() + .getSubscription(t.subscriptionId) + }.subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { + subscriptionEntity -> + handleFeedNotAvailable( + subscriptionEntity, + t.cause, + errors.subList(i + 1, errors.size) + ) + }, + { throwable -> throwable.printStackTrace() } + ) + return // this will be called on the remaining errors by handleFeedNotAvailable() + } + } + } + + private fun handleFeedNotAvailable( + subscriptionEntity: SubscriptionEntity, + @Nullable cause: Throwable?, + nextItemsErrors: List + ) { + val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) + val isFastFeedModeEnabled = sharedPreferences.getBoolean( + getString(R.string.feed_use_dedicated_fetch_method_key), false + ) + + val builder = AlertDialog.Builder(requireContext()) + .setTitle(R.string.feed_load_error) + .setPositiveButton( + R.string.unsubscribe + ) { _, _ -> + SubscriptionManager(requireContext()).deleteSubscription( + subscriptionEntity.serviceId, subscriptionEntity.url + ).subscribe() + handleItemsErrors(nextItemsErrors) + } + .setNegativeButton(R.string.cancel) { _, _ -> } + + var message = getString(R.string.feed_load_error_account_info, subscriptionEntity.name) + if (cause is AccountTerminatedException) { + message += "\n" + getString(R.string.feed_load_error_terminated) + } else if (cause is ContentNotAvailableException) { + if (isFastFeedModeEnabled) { + message += "\n" + getString(R.string.feed_load_error_fast_unknown) + builder.setNeutralButton(R.string.feed_use_dedicated_fetch_method_disable_button) { _, _ -> + sharedPreferences.edit { + putBoolean(getString(R.string.feed_use_dedicated_fetch_method_key), false) + } + } + } else if (!isNullOrEmpty(cause.message)) { + message += "\n" + cause.message + } + } + builder.setMessage(message).create().show() + } + private fun updateRelativeTimeViews() { updateRefreshViewState() - infoListAdapter.notifyDataSetChanged() + groupAdapter.notifyItemRangeChanged( + 0, groupAdapter.itemCount, + StreamItem.UPDATE_RELATIVE_TIME + ) } private fun updateRefreshViewState() { @@ -286,8 +518,6 @@ class FeedFragment : BaseListFragment() { // ///////////////////////////////////////////////////////////////////////// override fun doInitialLoadLogic() {} - override fun loadMoreItems() {} - override fun hasMoreItems() = false override fun reloadContent() { getActivity()?.startService( @@ -298,6 +528,35 @@ class FeedFragment : BaseListFragment() { listState = null } + // ///////////////////////////////////////////////////////////////////////// + // Grid Mode + // ///////////////////////////////////////////////////////////////////////// + + // TODO: Move these out of this class, as it can be reused + + private fun shouldUseGridLayout(): Boolean { + val listMode = PreferenceManager.getDefaultSharedPreferences(requireContext()) + .getString(getString(R.string.list_view_mode_key), getString(R.string.list_view_mode_value)) + + return when (listMode) { + getString(R.string.list_view_mode_auto_key) -> { + val configuration = resources.configuration + + ( + configuration.orientation == Configuration.ORIENTATION_LANDSCAPE && + configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE) + ) + } + getString(R.string.list_view_mode_grid_key) -> true + else -> false + } + } + + private fun getGridSpanCount(): Int { + val minWidth = resources.getDimensionPixelSize(R.dimen.video_item_grid_thumbnail_image_width) + return max(1, floor(resources.displayMetrics.widthPixels / minWidth.toDouble()).toInt()) + } + companion object { const val KEY_GROUP_ID = "ARG_GROUP_ID" const val KEY_GROUP_NAME = "ARG_GROUP_NAME" diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedState.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedState.kt index dec2773e124..27613e83e9c 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/FeedState.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedState.kt @@ -1,7 +1,7 @@ package org.schabi.newpipe.local.feed import androidx.annotation.StringRes -import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.local.feed.item.StreamItem import java.time.OffsetDateTime sealed class FeedState { @@ -12,7 +12,7 @@ sealed class FeedState { ) : FeedState() data class LoadedState( - val items: List, + val items: List, val oldestUpdate: OffsetDateTime? = null, val notLoadedCount: Long, val itemsErrors: List = emptyList() diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt index e516cdacaf8..8bdf412b5a2 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt @@ -8,9 +8,11 @@ import androidx.lifecycle.ViewModelProvider import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.functions.Function4 +import io.reactivex.rxjava3.processors.BehaviorProcessor import io.reactivex.rxjava3.schedulers.Schedulers import org.schabi.newpipe.database.feed.model.FeedGroupEntity -import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.database.stream.StreamWithState +import org.schabi.newpipe.local.feed.item.StreamItem import org.schabi.newpipe.local.feed.service.FeedEventManager import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ErrorResultEvent import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.IdleEvent @@ -20,26 +22,33 @@ import org.schabi.newpipe.util.DEFAULT_THROTTLE_TIMEOUT import java.time.OffsetDateTime import java.util.concurrent.TimeUnit -class FeedViewModel(applicationContext: Context, val groupId: Long = FeedGroupEntity.GROUP_ALL_ID) : ViewModel() { - class Factory(val context: Context, val groupId: Long = FeedGroupEntity.GROUP_ALL_ID) : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T { - return FeedViewModel(context.applicationContext, groupId) as T - } - } - +class FeedViewModel( + applicationContext: Context, + groupId: Long = FeedGroupEntity.GROUP_ALL_ID, + initialShowPlayedItems: Boolean = true +) : ViewModel() { private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(applicationContext) + private val toggleShowPlayedItems = BehaviorProcessor.create() + private val streamItems = toggleShowPlayedItems + .startWithItem(initialShowPlayedItems) + .distinctUntilChanged() + .switchMap { showPlayedItems -> + feedDatabaseManager.getStreams(groupId, showPlayedItems) + } + private val mutableStateLiveData = MutableLiveData() val stateLiveData: LiveData = mutableStateLiveData private var combineDisposable = Flowable .combineLatest( FeedEventManager.events(), - feedDatabaseManager.asStreamItems(groupId), + streamItems, feedDatabaseManager.notLoadedCount(groupId), feedDatabaseManager.oldestSubscriptionUpdate(groupId), - Function4 { t1: FeedEventManager.Event, t2: List, t3: Long, t4: List -> + + Function4 { t1: FeedEventManager.Event, t2: List, + t3: Long, t4: List -> return@Function4 CombineResultHolder(t1, t2, t3, t4.firstOrNull()) } ) @@ -49,9 +58,9 @@ class FeedViewModel(applicationContext: Context, val groupId: Long = FeedGroupEn .subscribe { (event, listFromDB, notLoadedCount, oldestUpdate) -> mutableStateLiveData.postValue( when (event) { - is IdleEvent -> FeedState.LoadedState(listFromDB, oldestUpdate, notLoadedCount) + is IdleEvent -> FeedState.LoadedState(listFromDB.map { e -> StreamItem(e) }, oldestUpdate, notLoadedCount) is ProgressEvent -> FeedState.ProgressState(event.currentProgress, event.maxProgress, event.progressMessage) - is SuccessResultEvent -> FeedState.LoadedState(listFromDB, oldestUpdate, notLoadedCount, event.itemsErrors) + is SuccessResultEvent -> FeedState.LoadedState(listFromDB.map { e -> StreamItem(e) }, oldestUpdate, notLoadedCount, event.itemsErrors) is ErrorResultEvent -> FeedState.ErrorState(event.error) } ) @@ -66,5 +75,20 @@ class FeedViewModel(applicationContext: Context, val groupId: Long = FeedGroupEn combineDisposable.dispose() } - private data class CombineResultHolder(val t1: FeedEventManager.Event, val t2: List, val t3: Long, val t4: OffsetDateTime?) + private data class CombineResultHolder(val t1: FeedEventManager.Event, val t2: List, val t3: Long, val t4: OffsetDateTime?) + + fun togglePlayedItems(showPlayedItems: Boolean) { + toggleShowPlayedItems.onNext(showPlayedItems) + } + + class Factory( + private val context: Context, + private val groupId: Long = FeedGroupEntity.GROUP_ALL_ID, + private val showPlayedItems: Boolean + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return FeedViewModel(context.applicationContext, groupId, showPlayedItems) as T + } + } } diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/item/StreamItem.kt b/app/src/main/java/org/schabi/newpipe/local/feed/item/StreamItem.kt new file mode 100644 index 00000000000..13ba7592b1f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/feed/item/StreamItem.kt @@ -0,0 +1,153 @@ +package org.schabi.newpipe.local.feed.item + +import android.content.Context +import android.text.TextUtils +import android.view.View +import androidx.core.content.ContextCompat +import androidx.preference.PreferenceManager +import com.nostra13.universalimageloader.core.ImageLoader +import com.xwray.groupie.viewbinding.BindableItem +import org.schabi.newpipe.MainActivity +import org.schabi.newpipe.R +import org.schabi.newpipe.database.stream.StreamWithState +import org.schabi.newpipe.database.stream.model.StreamEntity +import org.schabi.newpipe.databinding.ListStreamItemBinding +import org.schabi.newpipe.extractor.stream.StreamType.AUDIO_LIVE_STREAM +import org.schabi.newpipe.extractor.stream.StreamType.AUDIO_STREAM +import org.schabi.newpipe.extractor.stream.StreamType.LIVE_STREAM +import org.schabi.newpipe.extractor.stream.StreamType.VIDEO_STREAM +import org.schabi.newpipe.util.ImageDisplayConstants +import org.schabi.newpipe.util.Localization +import java.util.concurrent.TimeUnit + +data class StreamItem( + val streamWithState: StreamWithState, + var itemVersion: ItemVersion = ItemVersion.NORMAL +) : BindableItem() { + companion object { + const val UPDATE_RELATIVE_TIME = 1 + } + + private val stream: StreamEntity = streamWithState.stream + private val stateProgressTime: Long? = streamWithState.stateProgressMillis + + override fun getId(): Long = stream.uid + + enum class ItemVersion { NORMAL, MINI, GRID } + + override fun getLayout(): Int = when (itemVersion) { + ItemVersion.NORMAL -> R.layout.list_stream_item + ItemVersion.MINI -> R.layout.list_stream_mini_item + ItemVersion.GRID -> R.layout.list_stream_grid_item + } + + override fun initializeViewBinding(view: View) = ListStreamItemBinding.bind(view) + + override fun bind(viewBinding: ListStreamItemBinding, position: Int, payloads: MutableList) { + if (payloads.contains(UPDATE_RELATIVE_TIME)) { + if (itemVersion != ItemVersion.MINI) { + viewBinding.itemAdditionalDetails.text = + getStreamInfoDetailLine(viewBinding.itemAdditionalDetails.context) + } + return + } + + super.bind(viewBinding, position, payloads) + } + + override fun bind(viewBinding: ListStreamItemBinding, position: Int) { + viewBinding.itemVideoTitleView.text = stream.title + viewBinding.itemUploaderView.text = stream.uploader + + val isLiveStream = stream.streamType == LIVE_STREAM || stream.streamType == AUDIO_LIVE_STREAM + + if (stream.duration > 0) { + viewBinding.itemDurationView.text = Localization.getDurationString(stream.duration) + viewBinding.itemDurationView.setBackgroundColor( + ContextCompat.getColor( + viewBinding.itemDurationView.context, + R.color.duration_background_color + ) + ) + viewBinding.itemDurationView.visibility = View.VISIBLE + + if (stateProgressTime != null) { + viewBinding.itemProgressView.visibility = View.VISIBLE + viewBinding.itemProgressView.max = stream.duration.toInt() + viewBinding.itemProgressView.progress = TimeUnit.MILLISECONDS.toSeconds(stateProgressTime).toInt() + } else { + viewBinding.itemProgressView.visibility = View.GONE + } + } else if (isLiveStream) { + viewBinding.itemDurationView.setText(R.string.duration_live) + viewBinding.itemDurationView.setBackgroundColor( + ContextCompat.getColor( + viewBinding.itemDurationView.context, + R.color.live_duration_background_color + ) + ) + viewBinding.itemDurationView.visibility = View.VISIBLE + viewBinding.itemProgressView.visibility = View.GONE + } else { + viewBinding.itemDurationView.visibility = View.GONE + viewBinding.itemProgressView.visibility = View.GONE + } + + ImageLoader.getInstance().displayImage( + stream.thumbnailUrl, viewBinding.itemThumbnailView, + ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS + ) + + if (itemVersion != ItemVersion.MINI) { + viewBinding.itemAdditionalDetails.text = + getStreamInfoDetailLine(viewBinding.itemAdditionalDetails.context) + } + } + + override fun isLongClickable() = when (stream.streamType) { + AUDIO_STREAM, VIDEO_STREAM, LIVE_STREAM, AUDIO_LIVE_STREAM -> true + else -> false + } + + private fun getStreamInfoDetailLine(context: Context): String { + var viewsAndDate = "" + val viewCount = stream.viewCount + if (viewCount != null && viewCount >= 0) { + viewsAndDate = when (stream.streamType) { + AUDIO_LIVE_STREAM -> Localization.listeningCount(context, viewCount) + LIVE_STREAM -> Localization.shortWatchingCount(context, viewCount) + else -> Localization.shortViewCount(context, viewCount) + } + } + val uploadDate = getFormattedRelativeUploadDate(context) + return when { + !TextUtils.isEmpty(uploadDate) -> when { + viewsAndDate.isEmpty() -> uploadDate!! + else -> Localization.concatenateStrings(viewsAndDate, uploadDate) + } + else -> viewsAndDate + } + } + + private fun getFormattedRelativeUploadDate(context: Context): String? { + val uploadDate = stream.uploadDate + return if (uploadDate != null) { + var formattedRelativeTime = Localization.relativeTime(uploadDate) + + if (MainActivity.DEBUG) { + val key = context.getString(R.string.show_original_time_ago_key) + if (PreferenceManager.getDefaultSharedPreferences(context).getBoolean(key, false)) { + formattedRelativeTime += " (" + stream.textualUploadDate + ")" + } + } + + formattedRelativeTime + } else { + stream.textualUploadDate + } + } + + override fun getSpanSize(spanCount: Int, position: Int): Int { + return if (itemVersion == ItemVersion.GRID) 1 else spanCount + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt index 5ed7998d260..3638b4c0e9e 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt @@ -48,9 +48,7 @@ import org.schabi.newpipe.MainActivity.DEBUG import org.schabi.newpipe.R import org.schabi.newpipe.database.feed.model.FeedGroupEntity import org.schabi.newpipe.extractor.ListInfo -import org.schabi.newpipe.extractor.exceptions.ReCaptchaException import org.schabi.newpipe.extractor.stream.StreamInfoItem -import org.schabi.newpipe.ktx.isNetworkRelated import org.schabi.newpipe.local.feed.FeedDatabaseManager import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ErrorResultEvent import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ProgressEvent @@ -58,7 +56,6 @@ import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.SuccessResul import org.schabi.newpipe.local.feed.service.FeedEventManager.postEvent import org.schabi.newpipe.local.subscription.SubscriptionManager import org.schabi.newpipe.util.ExtractorHelper -import java.io.IOException import java.time.OffsetDateTime import java.time.ZoneOffset import java.util.concurrent.TimeUnit @@ -162,7 +159,7 @@ class FeedLoadService : Service() { // Loading & Handling // ///////////////////////////////////////////////////////////////////////// - private class RequestException(val subscriptionId: Long, message: String, cause: Throwable) : Exception(message, cause) { + class RequestException(val subscriptionId: Long, message: String, cause: Throwable) : Exception(message, cause) { companion object { fun wrapList(subscriptionId: Long, info: ListInfo): List { val toReturn = ArrayList(info.errors.size) @@ -209,29 +206,40 @@ class FeedLoadService : Service() { .filter { !cancelSignal.get() } .map { subscriptionEntity -> + var error: Throwable? = null try { val listInfo = if (useFeedExtractor) { ExtractorHelper .getFeedInfoFallbackToChannelInfo(subscriptionEntity.serviceId, subscriptionEntity.url) + .onErrorReturn { + error = it // store error, otherwise wrapped into RuntimeException + throw it + } .blockingGet() } else { ExtractorHelper .getChannelInfo(subscriptionEntity.serviceId, subscriptionEntity.url, true) + .onErrorReturn { + error = it // store error, otherwise wrapped into RuntimeException + throw it + } .blockingGet() } as ListInfo return@map Notification.createOnNext(Pair(subscriptionEntity.uid, listInfo)) } catch (e: Throwable) { + if (error == null) { + // do this to prevent blockingGet() from wrapping into RuntimeException + error = e + } + val request = "${subscriptionEntity.serviceId}:${subscriptionEntity.url}" - val wrapper = RequestException(subscriptionEntity.uid, request, e) + val wrapper = RequestException(subscriptionEntity.uid, request, error!!) return@map Notification.createOnError>>(wrapper) } } .sequential() - .observeOn(AndroidSchedulers.mainThread()) - .doOnNext(errorHandlingConsumer) - .observeOn(AndroidSchedulers.mainThread()) .doOnNext(notificationsConsumer) @@ -331,24 +339,6 @@ class FeedLoadService : Service() { } } - private val errorHandlingConsumer: Consumer>>> - get() = Consumer { - if (it.isOnError) { - var error = it.error!! - if (error is RequestException) error = error.cause!! - val cause = error.cause - - when { - error is ReCaptchaException -> throw error - cause is ReCaptchaException -> throw cause - - error is IOException -> throw error - cause is IOException -> throw cause - error.isNetworkRelated -> throw IOException(error) - } - } - } - private val notificationsConsumer: Consumer>>> get() = Consumer { onItemCompleted(it.value?.second?.name) } diff --git a/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java b/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java index 66f1bda0e4c..38ebe504ec1 100644 --- a/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java +++ b/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java @@ -211,11 +211,11 @@ public Maybe getStreamHistory(final StreamInfo info) { public Maybe loadStreamState(final PlayQueueItem queueItem) { return queueItem.getStream() - .map((info) -> streamTable.upsert(new StreamEntity(info))) + .map(info -> streamTable.upsert(new StreamEntity(info))) .flatMapPublisher(streamStateTable::getState) .firstElement() .flatMap(list -> list.isEmpty() ? Maybe.empty() : Maybe.just(list.get(0))) - .filter(state -> state.isValid((int) queueItem.getDuration())) + .filter(state -> state.isValid(queueItem.getDuration())) .subscribeOn(Schedulers.io()); } @@ -224,18 +224,16 @@ public Maybe loadStreamState(final StreamInfo info) { .flatMapPublisher(streamStateTable::getState) .firstElement() .flatMap(list -> list.isEmpty() ? Maybe.empty() : Maybe.just(list.get(0))) - .filter(state -> state.isValid((int) info.getDuration())) + .filter(state -> state.isValid(info.getDuration())) .subscribeOn(Schedulers.io()); } - public Completable saveStreamState(@NonNull final StreamInfo info, final long progressTime) { + public Completable saveStreamState(@NonNull final StreamInfo info, final long progressMillis) { return Completable.fromAction(() -> database.runInTransaction(() -> { final long streamId = streamTable.upsert(new StreamEntity(info)); - final StreamStateEntity state = new StreamStateEntity(streamId, progressTime); - if (state.isValid((int) info.getDuration())) { + final StreamStateEntity state = new StreamStateEntity(streamId, progressMillis); + if (state.isValid(info.getDuration())) { streamStateTable.upsert(state); - } else { - streamStateTable.deleteState(streamId); } })).subscribeOn(Schedulers.io()); } diff --git a/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java index 10aa8aa68df..aa871190f0f 100644 --- a/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java +++ b/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java @@ -36,7 +36,7 @@ import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.SinglePlayQueue; import org.schabi.newpipe.settings.HistorySettingsFragment; -import org.schabi.newpipe.util.KoreUtil; +import org.schabi.newpipe.util.external_communication.KoreUtils; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.OnClickGesture; import org.schabi.newpipe.util.StreamDialogEntry; @@ -111,7 +111,8 @@ public void setUserVisibleHint(final boolean isVisibleToUser) { } @Override - public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + public void onCreateOptionsMenu(@NonNull final Menu menu, + @NonNull final MenuInflater inflater) { super.onCreateOptionsMenu(menu, inflater); inflater.inflate(R.menu.menu_history, menu); } @@ -359,7 +360,7 @@ private void showStreamDialog(final StreamStatisticsEntry item) { )); } entries.add(StreamDialogEntry.open_in_browser); - if (KoreUtil.shouldShowPlayWithKodi(context, infoItem.getServiceId())) { + if (KoreUtils.shouldShowPlayWithKodi(context, infoItem.getServiceId())) { entries.add(StreamDialogEntry.play_with_kodi); } diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamItemHolder.java index fd6c8d1d1e5..903f104405e 100644 --- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamItemHolder.java @@ -68,11 +68,11 @@ public void updateFromItem(final LocalItem localItem, R.color.duration_background_color)); itemDurationView.setVisibility(View.VISIBLE); - if (item.getProgressTime() > 0) { + if (item.getProgressMillis() > 0) { itemProgressView.setVisibility(View.VISIBLE); itemProgressView.setMax((int) item.getStreamEntity().getDuration()); itemProgressView.setProgress((int) TimeUnit.MILLISECONDS - .toSeconds(item.getProgressTime())); + .toSeconds(item.getProgressMillis())); } else { itemProgressView.setVisibility(View.GONE); } @@ -109,14 +109,14 @@ public void updateState(final LocalItem localItem, } final PlaylistStreamEntry item = (PlaylistStreamEntry) localItem; - if (item.getProgressTime() > 0 && item.getStreamEntity().getDuration() > 0) { + if (item.getProgressMillis() > 0 && item.getStreamEntity().getDuration() > 0) { itemProgressView.setMax((int) item.getStreamEntity().getDuration()); if (itemProgressView.getVisibility() == View.VISIBLE) { itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS - .toSeconds(item.getProgressTime())); + .toSeconds(item.getProgressMillis())); } else { itemProgressView.setProgress((int) TimeUnit.MILLISECONDS - .toSeconds(item.getProgressTime())); + .toSeconds(item.getProgressMillis())); ViewUtils.animate(itemProgressView, true, 500); } } else if (itemProgressView.getVisibility() == View.VISIBLE) { diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java index 7c4e47c3654..adf6bd5c262 100644 --- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java @@ -96,11 +96,11 @@ public void updateFromItem(final LocalItem localItem, R.color.duration_background_color)); itemDurationView.setVisibility(View.VISIBLE); - if (item.getProgressTime() > 0) { + if (item.getProgressMillis() > 0) { itemProgressView.setVisibility(View.VISIBLE); itemProgressView.setMax((int) item.getStreamEntity().getDuration()); itemProgressView.setProgress((int) TimeUnit.MILLISECONDS - .toSeconds(item.getProgressTime())); + .toSeconds(item.getProgressMillis())); } else { itemProgressView.setVisibility(View.GONE); } @@ -140,14 +140,14 @@ public void updateState(final LocalItem localItem, } final StreamStatisticsEntry item = (StreamStatisticsEntry) localItem; - if (item.getProgressTime() > 0 && item.getStreamEntity().getDuration() > 0) { + if (item.getProgressMillis() > 0 && item.getStreamEntity().getDuration() > 0) { itemProgressView.setMax((int) item.getStreamEntity().getDuration()); if (itemProgressView.getVisibility() == View.VISIBLE) { itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS - .toSeconds(item.getProgressTime())); + .toSeconds(item.getProgressMillis())); } else { itemProgressView.setProgress((int) TimeUnit.MILLISECONDS - .toSeconds(item.getProgressTime())); + .toSeconds(item.getProgressMillis())); ViewUtils.animate(itemProgressView, true, 500); } } else if (itemProgressView.getVisibility() == View.VISIBLE) { diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java index f79282641cb..cefc63c0da0 100644 --- a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java +++ b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java @@ -44,7 +44,7 @@ import org.schabi.newpipe.player.helper.PlayerHolder; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.SinglePlayQueue; -import org.schabi.newpipe.util.KoreUtil; +import org.schabi.newpipe.util.external_communication.KoreUtils; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.OnClickGesture; @@ -248,7 +248,8 @@ public void onPause() { } @Override - public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + public void onCreateOptionsMenu(@NonNull final Menu menu, + @NonNull final MenuInflater inflater) { if (DEBUG) { Log.d(TAG, "onCreateOptionsMenu() called with: " + "menu = [" + menu + "], inflater = [" + inflater + "]"); @@ -770,7 +771,7 @@ protected void showStreamItemDialog(final PlaylistStreamEntry item) { )); } entries.add(StreamDialogEntry.open_in_browser); - if (KoreUtil.shouldShowPlayWithKodi(context, infoItem.getServiceId())) { + if (KoreUtils.shouldShowPlayWithKodi(context, infoItem.getServiceId())) { entries.add(StreamDialogEntry.play_with_kodi); } diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/ImportConfirmationDialog.java b/app/src/main/java/org/schabi/newpipe/local/subscription/ImportConfirmationDialog.java index 602e418a085..5ab0699eb5f 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/ImportConfirmationDialog.java +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/ImportConfirmationDialog.java @@ -23,13 +23,9 @@ public class ImportConfirmationDialog extends DialogFragment { public static void show(@NonNull final Fragment fragment, @NonNull final Intent resultServiceIntent) { - if (fragment.getFragmentManager() == null) { - return; - } - final ImportConfirmationDialog confirmationDialog = new ImportConfirmationDialog(); confirmationDialog.setResultServiceIntent(resultServiceIntent); - confirmationDialog.show(fragment.getFragmentManager(), null); + confirmationDialog.show(fragment.getParentFragmentManager(), null); } public void setResultServiceIntent(final Intent resultServiceIntent) { 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 8a235fa8abc..5b5bda498fd 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 @@ -16,12 +15,13 @@ import android.view.MenuInflater import android.view.View import android.view.ViewGroup import android.widget.Toast +import androidx.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import androidx.appcompat.app.AlertDialog 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 +52,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 org.schabi.newpipe.util.external_communication.ShareUtils import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -86,6 +84,11 @@ class SubscriptionFragment : BaseStateFragment() { private lateinit var feedGroupsSortMenuItem: HeaderWithMenuItem private val subscriptionsSection = Section() + private val requestExportLauncher = + registerForActivityResult(StartActivityForResult(), this::requestExportResult) + private val requestImportLauncher = + registerForActivityResult(StartActivityForResult(), this::requestImportResult) + @State @JvmField var itemsListState: Parcelable? = null @@ -188,44 +191,39 @@ class SubscriptionFragment : BaseStateFragment() { } private fun onImportPreviousSelected() { - startActivityForResult(FilePickerActivityHelper.chooseSingleFile(activity), REQUEST_IMPORT_CODE) + requestImportLauncher.launch(StoredFileHelper.getPicker(activity)) } 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) + requestExportLauncher.launch( + StoredFileHelper.getNewPicker(activity, exportName, "application/json", null) + ) } private fun openReorderDialog() { - FeedGroupReorderDialog().show(requireFragmentManager(), null) + FeedGroupReorderDialog().show(parentFragmentManager, null) } - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - 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) - ) - } - } 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) - ) - } + fun requestExportResult(result: ActivityResult) { + if (result.data != null && result.resultCode == Activity.RESULT_OK) { + activity.startService( + Intent(activity, SubscriptionsExportService::class.java) + .putExtra(SubscriptionsExportService.KEY_FILE_PATH, result.data?.data) + ) + } + } + + fun requestImportResult(result: ActivityResult) { + if (result.data != null && result.resultCode == Activity.RESULT_OK) { + ImportConfirmationDialog.show( + this, + Intent(activity, SubscriptionsImportService::class.java) + .putExtra(KEY_MODE, PREVIOUS_EXPORT_MODE) + .putExtra(KEY_VALUE, result.data?.data) + ) } } @@ -295,13 +293,17 @@ 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) ) val actions = DialogInterface.OnClickListener { _, i -> when (i) { - 0 -> ShareUtils.shareText(requireContext(), selectedItem.name, selectedItem.url) + 0 -> ShareUtils.shareText( + requireContext(), selectedItem.name, selectedItem.url, + selectedItem.thumbnailUrl + ) 1 -> ShareUtils.openUrlInBrowser(requireContext(), selectedItem.url) 2 -> deleteChannel(selectedItem) } @@ -444,9 +446,4 @@ class SubscriptionFragment : BaseStateFragment() { val minWidth = resources.getDimensionPixelSize(R.dimen.channel_item_grid_min_width) return max(1, floor(resources.displayMetrics.widthPixels / minWidth.toDouble()).toInt()) } - - companion object { - private const val REQUEST_EXPORT_CODE = 666 - private const val REQUEST_IMPORT_CODE = 667 - } } 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 f0675da1b79..4e667f2b97f 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 @@ -12,14 +12,15 @@ import android.widget.EditText; import android.widget.TextView; +import androidx.activity.result.ActivityResult; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult; import androidx.annotation.NonNull; import androidx.annotation.Nullable; 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; @@ -29,8 +30,8 @@ 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.streams.io.StoredFileHelper; import org.schabi.newpipe.util.Constants; -import org.schabi.newpipe.util.FilePickerActivityHelper; import org.schabi.newpipe.util.ServiceHelper; import java.util.Collections; @@ -45,8 +46,6 @@ import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE; public class SubscriptionsImportFragment extends BaseFragment { - private static final int REQUEST_IMPORT_FILE_CODE = 666; - @State int currentServiceId = Constants.NO_SERVICE_ID; @@ -64,6 +63,9 @@ public class SubscriptionsImportFragment extends BaseFragment { private EditText inputText; private Button inputButton; + private final ActivityResultLauncher requestImportFileLauncher = + registerForActivityResult(new StartActivityForResult(), this::requestImportFileResult); + public static SubscriptionsImportFragment getInstance(final int serviceId) { final SubscriptionsImportFragment instance = new SubscriptionsImportFragment(); instance.setInitialData(serviceId); @@ -175,23 +177,19 @@ public void onImportUrl(final String value) { } public void onImportFile() { - startActivityForResult(FilePickerActivityHelper.chooseSingleFile(activity), - REQUEST_IMPORT_FILE_CODE); + requestImportFileLauncher.launch(StoredFileHelper.getPicker(activity)); } - @Override - public void onActivityResult(final int requestCode, final int resultCode, final Intent data) { - super.onActivityResult(requestCode, resultCode, data); - if (data == null) { + private void requestImportFileResult(final ActivityResult result) { + if (result.getData() == null) { return; } - if (resultCode == Activity.RESULT_OK && requestCode == REQUEST_IMPORT_FILE_CODE - && data.getData() != null) { - final String path = Utils.getFileForUri(data.getData()).getAbsolutePath(); + if (result.getResultCode() == Activity.RESULT_OK && result.getData().getData() != null) { 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, result.getData().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 5dfb1bfe5b9..06310359706 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.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 int onStartCommand(final Intent intent, final int flags, final int startI 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 @@ private void startExport() { .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 void onSubscribe(final Subscription s) { } @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 void onComplete() { }; } - 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 af94934b251..a843ad77c4a 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.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.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 int onStartCommand(final Intent intent, final int flags, final int startI 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/player/PlayQueueActivity.java b/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java index 29c9ac77b60..13b66af8004 100644 --- a/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java +++ b/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java @@ -47,7 +47,7 @@ import static org.schabi.newpipe.player.helper.PlayerHelper.formatSpeed; import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; -import static org.schabi.newpipe.util.ShareUtils.shareText; +import static org.schabi.newpipe.util.external_communication.ShareUtils.shareText; public final class PlayQueueActivity extends AppCompatActivity implements PlayerEventListener, SeekBar.OnSeekBarChangeListener, @@ -313,7 +313,8 @@ private void buildItemPopupMenu(final PlayQueueItem item, final View view) { final MenuItem share = popupMenu.getMenu().add(RECYCLER_ITEM_POPUP_MENU_GROUP_ID, 3, Menu.NONE, R.string.share); share.setOnMenuItemClickListener(menuItem -> { - shareText(getApplicationContext(), item.getTitle(), item.getUrl()); + shareText(getApplicationContext(), item.getTitle(), item.getUrl(), + item.getThumbnailUrl()); return true; }); diff --git a/app/src/main/java/org/schabi/newpipe/player/Player.java b/app/src/main/java/org/schabi/newpipe/player/Player.java index d51a257897e..f5384a9d90b 100644 --- a/app/src/main/java/org/schabi/newpipe/player/Player.java +++ b/app/src/main/java/org/schabi/newpipe/player/Player.java @@ -123,11 +123,11 @@ import org.schabi.newpipe.player.resolver.VideoPlaybackResolver; import org.schabi.newpipe.util.DeviceUtils; import org.schabi.newpipe.util.ImageDisplayConstants; -import org.schabi.newpipe.util.KoreUtil; +import org.schabi.newpipe.util.external_communication.KoreUtils; import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.SerializedCache; -import org.schabi.newpipe.util.ShareUtils; +import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.views.ExpandableSurfaceView; import java.io.IOException; @@ -532,6 +532,7 @@ private void initListeners() { binding.moreOptionsButton.setOnClickListener(this); binding.moreOptionsButton.setOnLongClickListener(this); binding.share.setOnClickListener(this); + binding.share.setOnLongClickListener(this); binding.fullScreenButton.setOnClickListener(this); binding.screenRotationButton.setOnClickListener(this); binding.playWithKodi.setOnClickListener(this); @@ -670,7 +671,11 @@ && isPlaybackResumeEnabled(this) //.doFinally() .subscribe( state -> { - newQueue.setRecovery(newQueue.getIndex(), state.getProgressTime()); + if (!state.isFinished(newQueue.getItem().getDuration())) { + // resume playback only if the stream was not played to the end + newQueue.setRecovery(newQueue.getIndex(), + state.getProgressMillis()); + } initPlayback(newQueue, repeatMode, playbackSpeed, playbackPitch, playbackSkipSilence, playWhenReady, isMuted); }, @@ -1032,7 +1037,7 @@ private void showHideKodiButton() { // show kodi button if it supports the current service and it is enabled in settings binding.playWithKodi.setVisibility(videoPlayerSelected() && playQueue != null && playQueue.getItem() != null - && KoreUtil.shouldShowPlayWithKodi(context, playQueue.getItem().getServiceId()) + && KoreUtils.shouldShowPlayWithKodi(context, playQueue.getItem().getServiceId()) ? View.VISIBLE : View.GONE); } //endregion @@ -1934,9 +1939,7 @@ public void onPlayerStateChanged(final boolean playWhenReady, final int playback break; case com.google.android.exoplayer2.Player.STATE_ENDED: // 4 changeState(STATE_COMPLETED); - if (currentMetadata != null) { - resetStreamProgressState(currentMetadata.getMetadata()); - } + saveStreamProgressStateCompleted(); isPrepared = false; break; } @@ -2157,7 +2160,10 @@ private void onPausedSeek() { private void onCompleted() { if (DEBUG) { - Log.d(TAG, "onCompleted() called"); + Log.d(TAG, "onCompleted() called" + (playQueue == null ? ". playQueue is null" : "")); + } + if (playQueue == null) { + return; } animate(binding.playPauseButton, false, 0, AnimationType.SCALE_AND_ALPHA, 0, @@ -2397,7 +2403,7 @@ public void onPositionDiscontinuity(@DiscontinuityReason final int discontinuity case DISCONTINUITY_REASON_SEEK_ADJUSTMENT: case DISCONTINUITY_REASON_INTERNAL: if (playQueue.getIndex() != newWindowIndex) { - resetStreamProgressState(playQueue.getItem()); + saveStreamProgressStateCompleted(); // current stream has ended playQueue.setIndex(newWindowIndex); } break; @@ -2788,61 +2794,47 @@ private void registerStreamViewed() { } } - private void saveStreamProgressState(final StreamInfo info, final long progress) { - if (info == null) { + private void saveStreamProgressState(final long progressMillis) { + if (currentMetadata == null + || !prefs.getBoolean(context.getString(R.string.enable_watch_history_key), true)) { return; } if (DEBUG) { - Log.d(TAG, "saveStreamProgressState() called"); - } - if (prefs.getBoolean(context.getString(R.string.enable_watch_history_key), true)) { - final Disposable stateSaver = recordManager.saveStreamState(info, progress) - .observeOn(AndroidSchedulers.mainThread()) - .doOnError((e) -> { - if (DEBUG) { - e.printStackTrace(); - } - }) - .onErrorComplete() - .subscribe(); - databaseUpdateDisposable.add(stateSaver); - } - } - - private void resetStreamProgressState(final PlayQueueItem queueItem) { - if (queueItem == null) { - return; + Log.d(TAG, "saveStreamProgressState() called with: progressMillis=" + progressMillis + + ", currentMetadata=[" + currentMetadata.getMetadata().getName() + "]"); } - if (prefs.getBoolean(context.getString(R.string.enable_watch_history_key), true)) { - final Disposable stateSaver = queueItem.getStream() - .flatMapCompletable(info -> recordManager.saveStreamState(info, 0)) - .observeOn(AndroidSchedulers.mainThread()) - .doOnError((e) -> { - if (DEBUG) { - e.printStackTrace(); - } - }) - .onErrorComplete() - .subscribe(); - databaseUpdateDisposable.add(stateSaver); - } - } - private void resetStreamProgressState(final StreamInfo info) { - saveStreamProgressState(info, 0); + databaseUpdateDisposable + .add(recordManager.saveStreamState(currentMetadata.getMetadata(), progressMillis) + .observeOn(AndroidSchedulers.mainThread()) + .doOnError((e) -> { + if (DEBUG) { + e.printStackTrace(); + } + }) + .onErrorComplete() + .subscribe()); } public void saveStreamProgressState() { - if (exoPlayerIsNull() || currentMetadata == null) { + if (exoPlayerIsNull() || currentMetadata == null || playQueue == null + || playQueue.getIndex() != simpleExoPlayer.getCurrentWindowIndex()) { + // Make sure play queue and current window index are equal, to prevent saving state for + // the wrong stream on discontinuity (e.g. when the stream just changed but the + // playQueue index and currentMetadata still haven't updated) return; } - final StreamInfo currentInfo = currentMetadata.getMetadata(); - if (playQueue != null) { - // Save current position. It will help to restore this position once a user - // wants to play prev or next stream from the queue - playQueue.setRecovery(playQueue.getIndex(), simpleExoPlayer.getContentPosition()); + // Save current position. It will help to restore this position once a user + // wants to play prev or next stream from the queue + playQueue.setRecovery(playQueue.getIndex(), simpleExoPlayer.getContentPosition()); + saveStreamProgressState(simpleExoPlayer.getCurrentPosition()); + } + + public void saveStreamProgressStateCompleted() { + if (currentMetadata != null) { + // current stream has ended, so the progress is its duration (+1 to overcome rounding) + saveStreamProgressState((currentMetadata.getMetadata().getDuration() + 1) * 1000); } - saveStreamProgressState(currentInfo, simpleExoPlayer.getCurrentPosition()); } //endregion @@ -2917,6 +2909,18 @@ private String getVideoUrl() { : currentMetadata.getMetadata().getUrl(); } + @NonNull + private String getVideoUrlAtCurrentTime() { + final int timeSeconds = binding.playbackSeekBar.getProgress() / 1000; + String videoUrl = getVideoUrl(); + if (!isLive() && timeSeconds >= 0 && currentMetadata != null + && currentMetadata.getMetadata().getServiceId() == YouTube.getServiceId()) { + // Timestamp doesn't make sense in a live stream so drop it + videoUrl += ("&t=" + timeSeconds); + } + return videoUrl; + } + @NonNull public String getVideoTitle() { return currentMetadata == null @@ -3580,7 +3584,8 @@ public void onClick(final View v) { } else if (v.getId() == binding.moreOptionsButton.getId()) { onMoreOptionsClicked(); } else if (v.getId() == binding.share.getId()) { - onShareClicked(); + ShareUtils.shareText(context, getVideoTitle(), getVideoUrlAtCurrentTime(), + currentItem.getThumbnailUrl()); } else if (v.getId() == binding.playWithKodi.getId()) { onPlayWithKodiClicked(); } else if (v.getId() == binding.openInBrowser.getId()) { @@ -3629,6 +3634,8 @@ public boolean onLongClick(final View v) { fragmentListener.onMoreOptionsLongClicked(); hideControls(0, 0); hideSystemUIIfNeeded(); + } else if (v.getId() == binding.share.getId()) { + ShareUtils.copyToClipboard(context, getVideoUrlAtCurrentTime()); } return true; } @@ -3700,19 +3707,6 @@ private void onMoreOptionsClicked() { showControls(DEFAULT_CONTROLS_DURATION); } - private void onShareClicked() { - // share video at the current time (youtube.com/watch?v=ID&t=SECONDS) - // Timestamp doesn't make sense in a live stream so drop it - - final int ts = binding.playbackSeekBar.getProgress() / 1000; - String videoUrl = getVideoUrl(); - if (!isLive() && ts >= 0 && currentMetadata != null - && currentMetadata.getMetadata().getServiceId() == YouTube.getServiceId()) { - videoUrl += ("&t=" + ts); - } - ShareUtils.shareText(context, getVideoTitle(), videoUrl); - } - private void onPlayWithKodiClicked() { if (currentMetadata != null) { pause(); @@ -3722,7 +3716,7 @@ private void onPlayWithKodiClicked() { if (DEBUG) { Log.i(TAG, "Failed to start kore", e); } - KoreUtil.showInstallKoreDialog(getParentActivity()); + KoreUtils.showInstallKoreDialog(getParentActivity()); } } } diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueAdapter.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueAdapter.java index 462b9eb53db..dd95fb4d509 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueAdapter.java +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueAdapter.java @@ -182,8 +182,10 @@ public int getItemViewType(final int position) { return ITEM_VIEW_TYPE_ID; } + @NonNull @Override - public RecyclerView.ViewHolder onCreateViewHolder(final ViewGroup parent, final int type) { + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, + final int type) { switch (type) { case FOOTER_VIEW_TYPE_ID: return new HFHolder(footer); @@ -197,7 +199,8 @@ public RecyclerView.ViewHolder onCreateViewHolder(final ViewGroup parent, final } @Override - public void onBindViewHolder(final RecyclerView.ViewHolder holder, final int position) { + public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, + final int position) { if (holder instanceof PlayQueueItemHolder) { final PlayQueueItemHolder itemHolder = (PlayQueueItemHolder) holder; @@ -207,7 +210,6 @@ public void onBindViewHolder(final RecyclerView.ViewHolder holder, final int pos // Check if the current item should be selected/highlighted final boolean isSelected = playQueue.getIndex() == position; - itemHolder.itemSelected.setVisibility(isSelected ? View.VISIBLE : View.INVISIBLE); itemHolder.itemView.setSelected(isSelected); } else if (holder instanceof HFHolder && position == playQueue.getStreams().size() && footer != null && showFooter) { diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemHolder.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemHolder.java index c4641034359..1f2537baa50 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemHolder.java @@ -37,7 +37,6 @@ public class PlayQueueItemHolder extends RecyclerView.ViewHolder { public final TextView itemDurationView; final TextView itemAdditionalDetailsView; - final ImageView itemSelected; public final ImageView itemThumbnailView; final ImageView itemHandle; @@ -49,7 +48,6 @@ public class PlayQueueItemHolder extends RecyclerView.ViewHolder { itemVideoTitleView = v.findViewById(R.id.itemVideoTitleView); itemDurationView = v.findViewById(R.id.itemDurationView); itemAdditionalDetailsView = v.findViewById(R.id.itemAdditionalDetails); - itemSelected = v.findViewById(R.id.itemSelected); itemThumbnailView = v.findViewById(R.id.itemThumbnailView); itemHandle = v.findViewById(R.id.itemHandle); } 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 b64543d2732..8b2bd9c9adf 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 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 void onResume() { 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 9af3666a6e5..b6630075939 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,23 @@ package org.schabi.newpipe.settings; -import android.annotation.SuppressLint; import android.app.Activity; import android.content.Context; 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.activity.result.ActivityResult; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult; +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,74 +28,69 @@ 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.NavigationHelper; 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; + private final ActivityResultLauncher requestImportPathLauncher = + registerForActivityResult(new StartActivityForResult(), this::requestImportPathResult); + private final ActivityResultLauncher requestExportPathLauncher = + registerForActivityResult(new StartActivityForResult(), this::requestExportPathResult); @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(); + importExportDataPathKey = getString(R.string.import_export_data_path); + thumbnailLoadToggleKey = getString(R.string.download_thumbnail_key); + youtubeRestrictedModeEnabledKey = getString(R.string.youtube_restricted_mode_enabled); + 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); + final Preference importDataPreference = requirePreference(R.string.import_data); + importDataPreference.setOnPreferenceClickListener((Preference p) -> { + requestImportPathLauncher.launch( + StoredFileHelper.getPicker(requireContext(), getImportExportDataUri())); 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); + final Preference exportDataPreference = requirePreference(R.string.export_data); + exportDataPreference.setOnPreferenceClickListener((final Preference p) -> { + + requestExportPathLauncher.launch( + StoredFileHelper.getNewPicker(requireContext(), + "NewPipeData-" + EXPORT_DATE_FORMAT.format(new Date()) + ".zip", + ZIP_MIME_TYPE, getImportExportDataUri())); return true; }); - thumbnailLoadToggleKey = getString(R.string.download_thumbnail_key); - youtubeRestrictedModeEnabledKey = getString(R.string.youtube_restricted_mode_enabled); - initialSelectedLocalization = org.schabi.newpipe.util.Localization .getPreferredLocalization(requireContext()); initialSelectedContentCountry = org.schabi.newpipe.util.Localization @@ -101,8 +98,7 @@ public void onCreatePreferences(final Bundle savedInstanceState, final String ro 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(); @@ -163,64 +159,58 @@ public void onDestroy() { } } - @Override - public void onActivityResult(final int requestCode, final int resultCode, - @NonNull final Intent data) { + private void requestExportPathResult(final ActivityResult result) { assureCorrectAppLanguage(getContext()); - super.onActivityResult(requestCode, resultCode, data); - if (DEBUG) { - Log.d(TAG, "onActivityResult() called with: " - + "requestCode = [" + requestCode + "], " - + "resultCode = [" + resultCode + "], " - + "data = [" + data + "]"); - } + if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) { + lastImportExportDataUri = result.getData().getData(); // will be saved only on success - if ((requestCode == REQUEST_IMPORT_PATH || requestCode == REQUEST_EXPORT_PATH) - && resultCode == Activity.RESULT_OK && data.getData() != null) { - final File file = Utils.getFileForUri(data.getData()); + final StoredFileHelper file + = new StoredFileHelper(getContext(), result.getData().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()); - builder.create().show(); - } + exportDatabase(file); } } - private void exportDatabase(@NonNull final File folder) { - try { - final SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US); - final String path = folder.getAbsolutePath() + "/NewPipeData-" - + sdf.format(new Date()) + ".zip"; + private void requestImportPathResult(final ActivityResult result) { + assureCorrectAppLanguage(getContext()); + if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) { + lastImportExportDataUri = result.getData().getData(); // will be saved only on success + + final StoredFileHelper file + = new StoredFileHelper(getContext(), result.getData().getData(), ZIP_MIME_TYPE); + + new AlertDialog.Builder(requireActivity()) + .setMessage(R.string.override_current_data) + .setPositiveButton(R.string.finish, (d, id) -> + importDatabase(file)) + .setNegativeButton(R.string.cancel, (d, id) -> + d.cancel()) + .create() + .show(); + } + } + private void exportDatabase(final StoredFileHelper file) { + try { //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 @@ private void importDatabase(@NonNull final File file) { 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 @@ private void importDatabase(@NonNull final File file) { /** * 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); + NavigationHelper.restartApp(requireActivity()); + } + + private Uri getImportExportDataUri() { + final String path = defaultPreferences.getString(importExportDataPathKey, null); + return isBlank(path) ? null : Uri.parse(path); } - @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 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 1730a230e04..cb4c1479659 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 91351264466..a882acf3ba0 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.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.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 void onCreatePreferences(final Bundle savedInstanceState, final String ro 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 void onCreatePreferences(final Bundle savedInstanceState, final String ro } @Override - public void onAttach(final Context context) { + public void onAttach(@NonNull final Context context) { super.onAttach(context); ctx = context; } @@ -177,8 +188,14 @@ public boolean onPreferenceTreeClick(final Preference preference) { 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 boolean onPreferenceTreeClick(final Preference preference) { 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 01f51b0b3a3..33f00ec1a61 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java +++ b/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java @@ -2,16 +2,20 @@ import android.content.Context; import android.content.SharedPreferences; +import android.os.Build; import android.os.Environment; import androidx.annotation.NonNull; import androidx.preference.PreferenceManager; import org.schabi.newpipe.R; +import org.schabi.newpipe.util.DeviceUtils; 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 +69,36 @@ public static void initSettings(final Context context) { 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 +111,17 @@ private static String getNewPipeChildFolderPathForDir(final File dir) { } public static boolean useStorageAccessFramework(final Context context) { + // There's a FireOS bug which prevents SAF open/close dialogs from being confirmed with a + // remote (see #6455). + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP || DeviceUtils.isFireTv()) { + return false; + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + return true; + } + 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/PeertubeInstanceListFragment.java b/app/src/main/java/org/schabi/newpipe/settings/PeertubeInstanceListFragment.java index 550e9be3fc4..c7cfb1f5f83 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/PeertubeInstanceListFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/PeertubeInstanceListFragment.java @@ -139,7 +139,8 @@ public void onDestroy() { //////////////////////////////////////////////////////////////////////////*/ @Override - public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + public void onCreateOptionsMenu(@NonNull final Menu menu, + @NonNull final MenuInflater inflater) { super.onCreateOptionsMenu(menu, inflater); final MenuItem restoreItem = menu @@ -279,7 +280,7 @@ private ItemTouchHelper.SimpleCallback getItemTouchCallback() { return new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN, ItemTouchHelper.START | ItemTouchHelper.END) { @Override - public int interpolateOutOfBoundsScroll(final RecyclerView recyclerView, + public int interpolateOutOfBoundsScroll(@NonNull final RecyclerView recyclerView, final int viewSize, final int viewSizeOutOfBounds, final int totalSize, @@ -292,9 +293,9 @@ public int interpolateOutOfBoundsScroll(final RecyclerView recyclerView, } @Override - public boolean onMove(final RecyclerView recyclerView, - final RecyclerView.ViewHolder source, - final RecyclerView.ViewHolder target) { + public boolean onMove(@NonNull final RecyclerView recyclerView, + @NonNull final RecyclerView.ViewHolder source, + @NonNull final RecyclerView.ViewHolder target) { if (source.getItemViewType() != target.getItemViewType() || instanceListAdapter == null) { return false; @@ -317,7 +318,8 @@ public boolean isItemViewSwipeEnabled() { } @Override - public void onSwiped(final RecyclerView.ViewHolder viewHolder, final int swipeDir) { + public void onSwiped(@NonNull final RecyclerView.ViewHolder viewHolder, + final int swipeDir) { final int position = viewHolder.getAdapterPosition(); // do not allow swiping the selected instance if (instanceList.get(position).getUrl().equals(selectedInstance.getUrl())) { diff --git a/app/src/main/java/org/schabi/newpipe/settings/SelectKioskFragment.java b/app/src/main/java/org/schabi/newpipe/settings/SelectKioskFragment.java index 5c20b752ccb..9d873607614 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/SelectKioskFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/SelectKioskFragment.java @@ -8,6 +8,7 @@ import android.widget.ImageView; import android.widget.TextView; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.content.res.AppCompatResources; import androidx.fragment.app.DialogFragment; @@ -92,7 +93,7 @@ public View onCreateView(final LayoutInflater inflater, final ViewGroup containe //////////////////////////////////////////////////////////////////////////*/ @Override - public void onCancel(final DialogInterface dialogInterface) { + public void onCancel(@NonNull final DialogInterface dialogInterface) { super.onCancel(dialogInterface); if (onCancelListener != null) { onCancelListener.onCancel(); @@ -138,6 +139,7 @@ public int getItemCount() { return kioskList.size(); } + @NonNull public SelectKioskItemHolder onCreateViewHolder(final ViewGroup parent, final int type) { final View item = LayoutInflater.from(parent.getContext()) .inflate(R.layout.select_kiosk_item, parent, false); 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 c5974642831..2d5fedec09d 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 @@ import android.content.Context; import android.content.SharedPreferences; +import android.os.Build; import android.util.Log; import androidx.preference.PreferenceManager; @@ -10,6 +11,7 @@ import org.schabi.newpipe.error.ErrorActivity; import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.UserAction; +import org.schabi.newpipe.util.DeviceUtils; import static org.schabi.newpipe.MainActivity.DEBUG; @@ -18,7 +20,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 +56,22 @@ protected void migrate(final Context context) { } }; + 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. Also, there's a bug on FireOS in which SAF open/close + // dialogs cannot be confirmed with a remote (see #6455). + sp.edit().putBoolean(context.getString(R.string.storage_use_saf), + Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP + && !DeviceUtils.isFireTv()).apply(); + } + }; + /** * List of all implemented migrations. *

@@ -62,7 +80,8 @@ protected void migrate(final Context context) { */ 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 68908fc9248..02e2538c59a 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 @@ 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/settings/tabs/ChooseTabsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/tabs/ChooseTabsFragment.java index 9c1a9bdd71f..6e50765ba8d 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/tabs/ChooseTabsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/tabs/ChooseTabsFragment.java @@ -106,7 +106,8 @@ public void onPause() { //////////////////////////////////////////////////////////////////////////*/ @Override - public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + public void onCreateOptionsMenu(@NonNull final Menu menu, + @NonNull final MenuInflater inflater) { super.onCreateOptionsMenu(menu, inflater); final MenuItem restoreItem = menu.add(Menu.NONE, MENU_ITEM_RESTORE_ID, Menu.NONE, @@ -192,13 +193,13 @@ private void addTab(final int tabId) { final SelectKioskFragment selectKioskFragment = new SelectKioskFragment(); selectKioskFragment.setOnSelectedListener((serviceId, kioskId, kioskName) -> addTab(new Tab.KioskTab(serviceId, kioskId))); - selectKioskFragment.show(requireFragmentManager(), "select_kiosk"); + selectKioskFragment.show(getParentFragmentManager(), "select_kiosk"); return; case CHANNEL: final SelectChannelFragment selectChannelFragment = new SelectChannelFragment(); selectChannelFragment.setOnSelectedListener((serviceId, url, name) -> addTab(new Tab.ChannelTab(serviceId, url, name))); - selectChannelFragment.show(requireFragmentManager(), "select_channel"); + selectChannelFragment.show(getParentFragmentManager(), "select_channel"); return; case PLAYLIST: final SelectPlaylistFragment selectPlaylistFragment = new SelectPlaylistFragment(); @@ -215,7 +216,7 @@ public void onRemotePlaylistSelected( addTab(new Tab.PlaylistTab(serviceId, url, name)); } }); - selectPlaylistFragment.show(requireFragmentManager(), "select_playlist"); + selectPlaylistFragment.show(getParentFragmentManager(), "select_playlist"); return; default: addTab(type.getTab()); @@ -277,7 +278,7 @@ private ItemTouchHelper.SimpleCallback getItemTouchCallback() { return new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN, ItemTouchHelper.START | ItemTouchHelper.END) { @Override - public int interpolateOutOfBoundsScroll(final RecyclerView recyclerView, + public int interpolateOutOfBoundsScroll(@NonNull final RecyclerView recyclerView, final int viewSize, final int viewSizeOutOfBounds, final int totalSize, @@ -290,9 +291,9 @@ public int interpolateOutOfBoundsScroll(final RecyclerView recyclerView, } @Override - public boolean onMove(final RecyclerView recyclerView, - final RecyclerView.ViewHolder source, - final RecyclerView.ViewHolder target) { + public boolean onMove(@NonNull final RecyclerView recyclerView, + @NonNull final RecyclerView.ViewHolder source, + @NonNull final RecyclerView.ViewHolder target) { if (source.getItemViewType() != target.getItemViewType() || selectedTabsAdapter == null) { return false; @@ -315,7 +316,8 @@ public boolean isItemViewSwipeEnabled() { } @Override - public void onSwiped(final RecyclerView.ViewHolder viewHolder, final int swipeDir) { + public void onSwiped(@NonNull final RecyclerView.ViewHolder viewHolder, + final int swipeDir) { final int position = viewHolder.getAdapterPosition(); tabList.remove(position); selectedTabsAdapter.notifyItemRemoved(position); diff --git a/app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.java b/app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.java index b289009ceba..a148255b3f2 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.java +++ b/app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.java @@ -112,12 +112,16 @@ private static Tab from(final int tabId, @Nullable final JsonObject jsonObject) @Override public boolean equals(final Object obj) { - if (obj == this) { - return true; + if (!(obj instanceof Tab)) { + return false; } + final Tab other = (Tab) obj; + return getTabId() == other.getTabId(); + } - return obj instanceof Tab && obj.getClass() == this.getClass() - && ((Tab) obj).getTabId() == this.getTabId(); + @Override + public int hashCode() { + return Objects.hashCode(getTabId()); } /*////////////////////////////////////////////////////////////////////////// @@ -358,8 +362,18 @@ protected void readDataFromJson(final JsonObject jsonObject) { @Override public boolean equals(final Object obj) { - return super.equals(obj) && kioskServiceId == ((KioskTab) obj).kioskServiceId - && Objects.equals(kioskId, ((KioskTab) obj).kioskId); + if (!(obj instanceof KioskTab)) { + return false; + } + final KioskTab other = (KioskTab) obj; + return super.equals(obj) + && kioskServiceId == other.kioskServiceId + && kioskId.equals(other.kioskId); + } + + @Override + public int hashCode() { + return Objects.hash(getTabId(), kioskServiceId, kioskId); } public int getKioskServiceId() { @@ -432,9 +446,19 @@ protected void readDataFromJson(final JsonObject jsonObject) { @Override public boolean equals(final Object obj) { - return super.equals(obj) && channelServiceId == ((ChannelTab) obj).channelServiceId - && Objects.equals(channelUrl, ((ChannelTab) obj).channelUrl) - && Objects.equals(channelName, ((ChannelTab) obj).channelName); + if (!(obj instanceof ChannelTab)) { + return false; + } + final ChannelTab other = (ChannelTab) obj; + return super.equals(obj) + && channelServiceId == other.channelServiceId + && channelUrl.equals(other.channelName) + && channelName.equals(other.channelName); + } + + @Override + public int hashCode() { + return Objects.hash(getTabId(), channelServiceId, channelUrl, channelName); } public int getChannelServiceId() { @@ -576,15 +600,30 @@ protected void readDataFromJson(final JsonObject jsonObject) { @Override public boolean equals(final Object obj) { - if (!(super.equals(obj) - && Objects.equals(playlistType, ((PlaylistTab) obj).playlistType) - && Objects.equals(playlistName, ((PlaylistTab) obj).playlistName))) { - return false; // base objects are different + if (!(obj instanceof PlaylistTab)) { + return false; } - return (playlistId == ((PlaylistTab) obj).playlistId) // local - || (playlistServiceId == ((PlaylistTab) obj).playlistServiceId // remote - && Objects.equals(playlistUrl, ((PlaylistTab) obj).playlistUrl)); + final PlaylistTab other = (PlaylistTab) obj; + + return super.equals(obj) + && playlistServiceId == other.playlistServiceId // Remote + && playlistId == other.playlistId // Local + && playlistUrl.equals(other.playlistUrl) + && playlistName.equals(other.playlistName) + && playlistType == other.playlistType; + } + + @Override + public int hashCode() { + return Objects.hash( + getTabId(), + playlistServiceId, + playlistId, + playlistUrl, + playlistName, + playlistType + ); } public int getPlaylistServiceId() { 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 00000000000..956e9865cef --- /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 00000000000..76e3943123b --- /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 46ec68d9e53..849c7c05104 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 5edc5f3eda4..feca89f0272 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.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 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 StoredDirectoryHelper(@NonNull Context context, @NonNull Uri path, String 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 StoredFileHelper createUniqueFile(String name, String mime) { } 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 StoredFileHelper createUniqueFile(String name, String mime) { } // 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 boolean exists() { } /** - * 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 boolean mkdirs() { return ioTree.exists() || ioTree.mkdirs(); } - if (docTree.exists()) return true; + if (docTree.exists()) { + return true; + } try { DocumentFile parent; @@ -177,14 +187,18 @@ public boolean mkdirs() { 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 String getTag() { 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 boolean canWrite() { 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 00000000000..c86164ed2e5 --- /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/CommentTextOnTouchListener.java b/app/src/main/java/org/schabi/newpipe/util/CommentTextOnTouchListener.java index d26116139f0..7c87e664ba8 100644 --- a/app/src/main/java/org/schabi/newpipe/util/CommentTextOnTouchListener.java +++ b/app/src/main/java/org/schabi/newpipe/util/CommentTextOnTouchListener.java @@ -1,6 +1,5 @@ package org.schabi.newpipe.util; -import android.content.Context; import android.text.Layout; import android.text.Selection; import android.text.Spannable; @@ -11,27 +10,14 @@ import android.view.View; import android.widget.TextView; -import org.schabi.newpipe.extractor.NewPipe; -import org.schabi.newpipe.extractor.StreamingService; -import org.schabi.newpipe.extractor.exceptions.ExtractionException; -import org.schabi.newpipe.extractor.exceptions.ParsingException; -import org.schabi.newpipe.extractor.linkhandler.LinkHandlerFactory; -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.player.playqueue.PlayQueue; -import org.schabi.newpipe.player.playqueue.SinglePlayQueue; +import org.schabi.newpipe.util.external_communication.ShareUtils; +import org.schabi.newpipe.util.external_communication.InternalUrlsHandler; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.schedulers.Schedulers; +import io.reactivex.rxjava3.disposables.CompositeDisposable; public class CommentTextOnTouchListener implements View.OnTouchListener { public static final CommentTextOnTouchListener INSTANCE = new CommentTextOnTouchListener(); - private static final Pattern TIMESTAMP_PATTERN = Pattern.compile("(.*)#timestamp=(\\d+)"); - @Override public boolean onTouch(final View v, final MotionEvent event) { if (!(v instanceof TextView)) { @@ -64,13 +50,12 @@ public boolean onTouch(final View v, final MotionEvent event) { if (link.length != 0) { if (action == MotionEvent.ACTION_UP) { - boolean handled = false; if (link[0] instanceof URLSpan) { - handled = handleUrl(v.getContext(), (URLSpan) link[0]); - } - if (!handled) { - ShareUtils.openUrlInBrowser(v.getContext(), - ((URLSpan) link[0]).getURL(), false); + final String url = ((URLSpan) link[0]).getURL(); + if (!InternalUrlsHandler.handleUrlCommentsTimestamp( + new CompositeDisposable(), v.getContext(), url)) { + ShareUtils.openUrlInBrowser(v.getContext(), url, false); + } } } else if (action == MotionEvent.ACTION_DOWN) { Selection.setSelection(buffer, @@ -83,52 +68,4 @@ public boolean onTouch(final View v, final MotionEvent event) { } return false; } - - private boolean handleUrl(final Context context, final URLSpan urlSpan) { - String url = urlSpan.getURL(); - int seconds = -1; - final Matcher matcher = TIMESTAMP_PATTERN.matcher(url); - if (matcher.matches()) { - url = matcher.group(1); - seconds = Integer.parseInt(matcher.group(2)); - } - final StreamingService service; - final StreamingService.LinkType linkType; - try { - service = NewPipe.getServiceByUrl(url); - linkType = service.getLinkTypeByUrl(url); - } catch (final ExtractionException e) { - return false; - } - if (linkType == StreamingService.LinkType.NONE) { - return false; - } - if (linkType == StreamingService.LinkType.STREAM && seconds != -1) { - return playOnPopup(context, url, service, seconds); - } else { - NavigationHelper.openRouterActivity(context, url); - return true; - } - } - - private boolean playOnPopup(final Context context, final String url, - final StreamingService service, final int seconds) { - final LinkHandlerFactory factory = service.getStreamLHFactory(); - final String cleanUrl; - try { - cleanUrl = factory.getUrl(factory.getId(url)); - } catch (final ParsingException e) { - return false; - } - final Single single - = ExtractorHelper.getStreamInfo(service.getServiceId(), cleanUrl, false); - single.subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(info -> { - final PlayQueue playQueue - = new SinglePlayQueue((StreamInfo) info, seconds * 1000); - NavigationHelper.playOnPopupPlayer(context, playQueue, false); - }); - return true; - } } diff --git a/app/src/main/java/org/schabi/newpipe/util/DeviceUtils.java b/app/src/main/java/org/schabi/newpipe/util/DeviceUtils.java index 91578f27ee2..8d918c162c9 100644 --- a/app/src/main/java/org/schabi/newpipe/util/DeviceUtils.java +++ b/app/src/main/java/org/schabi/newpipe/util/DeviceUtils.java @@ -12,13 +12,16 @@ import androidx.annotation.Dimension; import androidx.annotation.NonNull; import androidx.core.content.ContextCompat; +import androidx.preference.PreferenceManager; import org.schabi.newpipe.App; +import org.schabi.newpipe.R; public final class DeviceUtils { private static final String AMAZON_FEATURE_FIRE_TV = "amazon.hardware.fire_tv"; private static Boolean isTV = null; + private static Boolean isFireTV = null; /* * Devices that do not support media tunneling @@ -33,6 +36,16 @@ public final class DeviceUtils { private DeviceUtils() { } + public static boolean isFireTv() { + if (isFireTV != null) { + return isFireTV; + } + + isFireTV = + App.getApp().getPackageManager().hasSystemFeature(AMAZON_FEATURE_FIRE_TV); + return isFireTV; + } + public static boolean isTv(final Context context) { if (isTV != null) { return isTV; @@ -43,7 +56,7 @@ public static boolean isTv(final Context context) { // from doc: https://developer.android.com/training/tv/start/hardware.html#runtime-check boolean isTv = ContextCompat.getSystemService(context, UiModeManager.class) .getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION - || pm.hasSystemFeature(AMAZON_FEATURE_FIRE_TV) + || isFireTv() || pm.hasSystemFeature(PackageManager.FEATURE_TELEVISION); // from https://stackoverflow.com/a/58932366 @@ -65,10 +78,18 @@ public static boolean isTv(final Context context) { } public static boolean isTablet(@NonNull final Context context) { - return (context - .getResources() - .getConfiguration().screenLayout & Configuration.SCREENLAYOUT_SIZE_MASK) - >= Configuration.SCREENLAYOUT_SIZE_LARGE; + final String tabletModeSetting = PreferenceManager.getDefaultSharedPreferences(context) + .getString(context.getString(R.string.tablet_mode_key), ""); + + if (tabletModeSetting.equals(context.getString(R.string.tablet_mode_on_key))) { + return true; + } else if (tabletModeSetting.equals(context.getString(R.string.tablet_mode_off_key))) { + return false; + } + + // else automatically determine whether we are in a tablet or not + return (context.getResources().getConfiguration().screenLayout + & Configuration.SCREENLAYOUT_SIZE_MASK) >= Configuration.SCREENLAYOUT_SIZE_LARGE; } public static boolean isConfirmKey(final int keyCode) { diff --git a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java index af7cafc1518..af94e3366a9 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java @@ -30,6 +30,7 @@ import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.R; +import org.schabi.newpipe.util.external_communication.TextLinkifier; import org.schabi.newpipe.extractor.Info; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.ListExtractor.InfoItemsPage; @@ -54,7 +55,7 @@ import io.reactivex.rxjava3.core.Maybe; import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.disposables.Disposable; +import io.reactivex.rxjava3.disposables.CompositeDisposable; import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; @@ -268,18 +269,19 @@ public static boolean isCached(final int serviceId, final String url, * @param metaInfos a list of meta information, can be null or empty * @param metaInfoTextView the text view in which to show the formatted HTML * @param metaInfoSeparator another view to be shown or hidden accordingly to the text view - * @return a disposable to be stored somewhere and disposed when activity/fragment is destroyed + * @param disposables disposables created by the method are added here and their lifecycle + * should be handled by the calling class */ - public static Disposable showMetaInfoInTextView(@Nullable final List metaInfos, - final TextView metaInfoTextView, - final View metaInfoSeparator) { + public static void showMetaInfoInTextView(@Nullable final List metaInfos, + final TextView metaInfoTextView, + final View metaInfoSeparator, + final CompositeDisposable disposables) { final Context context = metaInfoTextView.getContext(); if (metaInfos == null || metaInfos.isEmpty() || !PreferenceManager.getDefaultSharedPreferences(context).getBoolean( context.getString(R.string.show_meta_info_key), true)) { metaInfoTextView.setVisibility(View.GONE); metaInfoSeparator.setVisibility(View.GONE); - return Disposable.empty(); } else { final StringBuilder stringBuilder = new StringBuilder(); @@ -310,8 +312,8 @@ public static Disposable showMetaInfoInTextView(@Nullable final List m } metaInfoSeparator.setVisibility(View.VISIBLE); - return TextLinkifier.createLinksFromHtmlBlock(context, stringBuilder.toString(), - metaInfoTextView, HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_HEADING); + TextLinkifier.createLinksFromHtmlBlock(metaInfoTextView, stringBuilder.toString(), + HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_HEADING, null, disposables); } } 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 4162e563af5..00000000000 --- 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 6ede163a3b8..20d8ce30c3d 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 @@ 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/NavigationHelper.java b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java index 106399735ab..67eeb2eb3cb 100644 --- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java @@ -21,6 +21,7 @@ import com.nostra13.universalimageloader.core.ImageLoader; import org.schabi.newpipe.MainActivity; +import org.schabi.newpipe.NewPipeDatabase; import org.schabi.newpipe.R; import org.schabi.newpipe.RouterActivity; import org.schabi.newpipe.about.AboutActivity; @@ -53,10 +54,11 @@ import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueueItem; import org.schabi.newpipe.settings.SettingsActivity; +import org.schabi.newpipe.util.external_communication.ShareUtils; import java.util.ArrayList; -import static org.schabi.newpipe.util.ShareUtils.installApp; +import static org.schabi.newpipe.util.external_communication.ShareUtils.installApp; public final class NavigationHelper { public static final String MAIN_FRAGMENT_TAG = "main_fragment_tag"; @@ -252,7 +254,7 @@ public static void playOnExternalPlayer(final Context context, final String name public static void resolveActivityOrAskToInstall(final Context context, final Intent intent) { if (intent.resolveActivity(context.getPackageManager()) != null) { - ShareUtils.openIntentInApp(context, intent); + ShareUtils.openIntentInApp(context, intent, false); } else { if (context instanceof Activity) { new AlertDialog.Builder(context) @@ -599,4 +601,17 @@ public static void playWithKore(final Context context, final Uri videoURL) { intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); context.startActivity(intent); } + + /** + * Finish this Activity as well as all Activities running below it + * and then start MainActivity. + * + * @param activity the activity to finish + */ + public static void restartApp(final Activity activity) { + NewPipeDatabase.close(); + activity.finishAffinity(); + final Intent intent = new Intent(activity, MainActivity.class); + activity.startActivity(intent); + } } 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 03400bdbb0b..c64631b7266 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.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/ShareUtils.java b/app/src/main/java/org/schabi/newpipe/util/ShareUtils.java deleted file mode 100644 index 45ec1d01557..00000000000 --- a/app/src/main/java/org/schabi/newpipe/util/ShareUtils.java +++ /dev/null @@ -1,229 +0,0 @@ -package org.schabi.newpipe.util; - -import android.content.ActivityNotFoundException; -import android.content.ClipData; -import android.content.ClipboardManager; -import android.content.Context; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.content.pm.ResolveInfo; -import android.net.Uri; -import android.widget.Toast; - -import androidx.core.content.ContextCompat; - -import org.schabi.newpipe.R; - -public final class ShareUtils { - private ShareUtils() { - } - - /** - * Open an Intent to install an app. - *

- * This method tries to open the default app market with the package id passed as the - * second param (a system chooser will be opened if there are multiple markets and no default) - * and falls back to Google Play Store web URL if no app to handle the market scheme was found. - *

- * It uses {@link ShareUtils#openIntentInApp(Context, Intent)} to open market scheme and - * {@link ShareUtils#openUrlInBrowser(Context, String, boolean)} to open Google Play Store web - * URL with false for the boolean param. - * - * @param context the context to use - * @param packageId the package id of the app to be installed - */ - public static void installApp(final Context context, final String packageId) { - // Try market:// scheme - final boolean marketSchemeResult = openIntentInApp(context, new Intent(Intent.ACTION_VIEW, - Uri.parse("market://details?id=" + packageId)) - .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); - if (!marketSchemeResult) { - // Fall back to Google Play Store Web URL (F-Droid can handle it) - openUrlInBrowser(context, - "https://play.google.com/store/apps/details?id=" + packageId, false); - } - } - - /** - * Open the url with the system default browser. - *

- * If no browser is set as default, fallbacks to - * {@link ShareUtils#openAppChooser(Context, Intent, String)} - * - * @param context the context to use - * @param url the url to browse - * @param httpDefaultBrowserTest the boolean to set if the test for the default browser will be - * for HTTP protocol or for the created intent - * @return true if the URL can be opened or false if it cannot - */ - public static boolean openUrlInBrowser(final Context context, final String url, - final boolean httpDefaultBrowserTest) { - final String defaultPackageName; - final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)) - .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - - if (httpDefaultBrowserTest) { - defaultPackageName = getDefaultAppPackageName(context, new Intent(Intent.ACTION_VIEW, - Uri.parse("http://")).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); - } else { - defaultPackageName = getDefaultAppPackageName(context, intent); - } - - if (defaultPackageName.equals("android")) { - // No browser set as default (doesn't work on some devices) - openAppChooser(context, intent, context.getString(R.string.open_with)); - } else { - if (defaultPackageName.isEmpty()) { - // No app installed to open a web url - Toast.makeText(context, R.string.no_app_to_open_intent, Toast.LENGTH_LONG).show(); - return false; - } else { - try { - intent.setPackage(defaultPackageName); - context.startActivity(intent); - } catch (final ActivityNotFoundException e) { - // Not a browser but an app chooser because of OEMs changes - intent.setPackage(null); - openAppChooser(context, intent, context.getString(R.string.open_with)); - } - } - } - - return true; - } - - /** - * Open the url with the system default browser. - *

- * If no browser is set as default, fallbacks to - * {@link ShareUtils#openAppChooser(Context, Intent, String)} - *

- * This calls {@link ShareUtils#openUrlInBrowser(Context, String, boolean)} with true - * for the boolean parameter - * - * @param context the context to use - * @param url the url to browse - * @return true if the URL can be opened or false if it cannot be - **/ - public static boolean openUrlInBrowser(final Context context, final String url) { - return openUrlInBrowser(context, url, true); - } - - /** - * Open an intent with the system default app. - *

- * The intent can be of every type, excepted a web intent for which - * {@link ShareUtils#openUrlInBrowser(Context, String, boolean)} should be used. - *

- * If no app is set as default, fallbacks to - * {@link ShareUtils#openAppChooser(Context, Intent, String)} - * - * @param context the context to use - * @param intent the intent to open - * @return true if the intent can be opened or false if it cannot be - */ - public static boolean openIntentInApp(final Context context, final Intent intent) { - final String defaultPackageName = getDefaultAppPackageName(context, intent); - - if (defaultPackageName.equals("android")) { - // No app set as default (doesn't work on some devices) - openAppChooser(context, intent, context.getString(R.string.open_with)); - } else { - if (defaultPackageName.isEmpty()) { - // No app installed to open the intent - Toast.makeText(context, R.string.no_app_to_open_intent, Toast.LENGTH_LONG).show(); - return false; - } else { - try { - intent.setPackage(defaultPackageName); - context.startActivity(intent); - } catch (final ActivityNotFoundException e) { - // Not an app to open the intent but an app chooser because of OEMs changes - intent.setPackage(null); - openAppChooser(context, intent, context.getString(R.string.open_with)); - } - } - } - - return true; - } - - /** - * Open the system chooser to launch an intent. - *

- * This method opens an {@link android.content.Intent#ACTION_CHOOSER} of the intent putted - * as the viewIntent param. A string for the chooser's title must be passed as the last param. - * - * @param context the context to use - * @param intent the intent to open - * @param chooserStringTitle the string of chooser's title - */ - private static void openAppChooser(final Context context, final Intent intent, - final String chooserStringTitle) { - final Intent chooserIntent = new Intent(Intent.ACTION_CHOOSER); - chooserIntent.putExtra(Intent.EXTRA_INTENT, intent); - chooserIntent.putExtra(Intent.EXTRA_TITLE, chooserStringTitle); - chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - context.startActivity(chooserIntent); - } - - /** - * Get the default app package name. - *

- * If no app is set as default, it will return "android" (not on some devices because some - * OEMs changed the app chooser). - *

- * If no app is installed on user's device to handle the intent, it will return an empty string. - * - * @param context the context to use - * @param intent the intent to get default app - * @return the package name of the default app, an empty string if there's no app installed to - * handle the intent or the app chooser if there's no default - */ - private static String getDefaultAppPackageName(final Context context, final Intent intent) { - final ResolveInfo resolveInfo = context.getPackageManager().resolveActivity(intent, - PackageManager.MATCH_DEFAULT_ONLY); - - if (resolveInfo == null) { - return ""; - } else { - return resolveInfo.activityInfo.packageName; - } - } - - /** - * Open the android share menu to share the current url. - * - * @param context the context to use - * @param subject the url subject, typically the title - * @param url the url to share - */ - public static void shareText(final Context context, final String subject, final String url) { - final Intent shareIntent = new Intent(Intent.ACTION_SEND); - shareIntent.setType("text/plain"); - shareIntent.putExtra(Intent.EXTRA_SUBJECT, subject); - shareIntent.putExtra(Intent.EXTRA_TEXT, url); - - openAppChooser(context, shareIntent, context.getString(R.string.share_dialog_title)); - } - - /** - * Copy the text to clipboard, and indicate to the user whether the operation was completed - * successfully using a Toast. - * - * @param context the context to use - * @param text the text to copy - */ - public static void copyToClipboard(final Context context, final String text) { - final ClipboardManager clipboardManager = - ContextCompat.getSystemService(context, ClipboardManager.class); - - if (clipboardManager == null) { - Toast.makeText(context, R.string.permission_denied, Toast.LENGTH_LONG).show(); - return; - } - - clipboardManager.setPrimaryClip(ClipData.newPlainText(null, text)); - Toast.makeText(context, R.string.msg_copied, Toast.LENGTH_SHORT).show(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/StreamDialogEntry.java b/app/src/main/java/org/schabi/newpipe/util/StreamDialogEntry.java index 610f9f85237..50eeef7e730 100644 --- a/app/src/main/java/org/schabi/newpipe/util/StreamDialogEntry.java +++ b/app/src/main/java/org/schabi/newpipe/util/StreamDialogEntry.java @@ -12,6 +12,8 @@ import org.schabi.newpipe.player.MainPlayer; import org.schabi.newpipe.player.helper.PlayerHolder; import org.schabi.newpipe.player.playqueue.SinglePlayQueue; +import org.schabi.newpipe.util.external_communication.KoreUtils; +import org.schabi.newpipe.util.external_communication.ShareUtils; import java.util.Collections; import java.util.List; @@ -66,16 +68,14 @@ public enum StreamDialogEntry { }), // has to be set manually append_playlist(R.string.append_playlist, (fragment, item) -> { - if (fragment.getFragmentManager() != null) { - final PlaylistAppendDialog d = PlaylistAppendDialog - .fromStreamInfoItems(Collections.singletonList(item)); - - PlaylistAppendDialog.onPlaylistFound(fragment.getContext(), - () -> d.show(fragment.getFragmentManager(), "StreamDialogEntry@append_playlist"), - () -> PlaylistCreationDialog.newInstance(d) - .show(fragment.getFragmentManager(), "StreamDialogEntry@create_playlist") - ); - } + final PlaylistAppendDialog d = PlaylistAppendDialog + .fromStreamInfoItems(Collections.singletonList(item)); + + PlaylistAppendDialog.onPlaylistFound(fragment.getContext(), + () -> d.show(fragment.getParentFragmentManager(), "StreamDialogEntry@append_playlist"), + () -> PlaylistCreationDialog.newInstance(d) + .show(fragment.getParentFragmentManager(), "StreamDialogEntry@create_playlist") + ); }), play_with_kodi(R.string.play_with_kodi_title, (fragment, item) -> { @@ -83,12 +83,13 @@ public enum StreamDialogEntry { try { NavigationHelper.playWithKore(fragment.requireContext(), videoUrl); } catch (final Exception e) { - KoreUtil.showInstallKoreDialog(fragment.getActivity()); + KoreUtils.showInstallKoreDialog(fragment.getActivity()); } }), share(R.string.share, (fragment, item) -> - ShareUtils.shareText(fragment.getContext(), item.getName(), item.getUrl())), + ShareUtils.shareText(fragment.getContext(), item.getName(), item.getUrl(), + item.getThumbnailUrl())), open_in_browser(R.string.open_in_browser, (fragment, item) -> ShareUtils.openUrlInBrowser(fragment.getContext(), item.getUrl())); diff --git a/app/src/main/java/org/schabi/newpipe/util/TextLinkifier.java b/app/src/main/java/org/schabi/newpipe/util/TextLinkifier.java deleted file mode 100644 index 08767733339..00000000000 --- a/app/src/main/java/org/schabi/newpipe/util/TextLinkifier.java +++ /dev/null @@ -1,145 +0,0 @@ -package org.schabi.newpipe.util; - -import android.content.Context; -import android.text.SpannableStringBuilder; -import android.text.method.LinkMovementMethod; -import android.text.style.ClickableSpan; -import android.text.style.URLSpan; -import android.text.util.Linkify; -import android.util.Log; -import android.view.View; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.core.text.HtmlCompat; - -import io.noties.markwon.Markwon; -import io.noties.markwon.linkify.LinkifyPlugin; -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.disposables.Disposable; -import io.reactivex.rxjava3.schedulers.Schedulers; - -public final class TextLinkifier { - public static final String TAG = TextLinkifier.class.getSimpleName(); - - private TextLinkifier() { - } - - /** - * Create web links for contents with an HTML description. - *

- * This will call - * {@link TextLinkifier#changeIntentsOfDescriptionLinks(Context, CharSequence, TextView)} - * after having linked the URLs with {@link HtmlCompat#fromHtml(String, int)}. - * - * @param context the context to use - * @param htmlBlock the htmlBlock to be linked - * @param textView the TextView to set the htmlBlock linked - * @param htmlCompatFlag the int flag to be set when {@link HtmlCompat#fromHtml(String, int)} - * will be called - * @return a disposable to be stored somewhere and disposed when activity/fragment is destroyed - */ - public static Disposable createLinksFromHtmlBlock(final Context context, - final String htmlBlock, - final TextView textView, - final int htmlCompatFlag) { - return changeIntentsOfDescriptionLinks(context, - HtmlCompat.fromHtml(htmlBlock, htmlCompatFlag), textView); - } - - /** - * Create web links for contents with a plain text description. - *

- * This will call - * {@link TextLinkifier#changeIntentsOfDescriptionLinks(Context, CharSequence, TextView)} - * after having linked the URLs with {@link TextView#setAutoLinkMask(int)} and - * {@link TextView#setText(CharSequence, TextView.BufferType)}. - * - * @param context the context to use - * @param plainTextBlock the block of plain text to be linked - * @param textView the TextView to set the plain text block linked - * @return a disposable to be stored somewhere and disposed when activity/fragment is destroyed - */ - public static Disposable createLinksFromPlainText(final Context context, - final String plainTextBlock, - final TextView textView) { - textView.setAutoLinkMask(Linkify.WEB_URLS); - textView.setText(plainTextBlock, TextView.BufferType.SPANNABLE); - return changeIntentsOfDescriptionLinks(context, textView.getText(), textView); - } - - /** - * Create web links for contents with a markdown description. - *

- * This will call - * {@link TextLinkifier#changeIntentsOfDescriptionLinks(Context, CharSequence, TextView)} - * after creating an {@link Markwon} object and using - * {@link Markwon#setMarkdown(TextView, String)}. - * - * @param context the context to use - * @param markdownBlock the block of markdown text to be linked - * @param textView the TextView to set the plain text block linked - * @return a disposable to be stored somewhere and disposed when activity/fragment is destroyed - */ - public static Disposable createLinksFromMarkdownText(final Context context, - final String markdownBlock, - final TextView textView) { - final Markwon markwon = Markwon.builder(context).usePlugin(LinkifyPlugin.create()).build(); - markwon.setMarkdown(textView, markdownBlock); - return changeIntentsOfDescriptionLinks(context, textView.getText(), textView); - } - - /** - * Change links generated by libraries in the description of a content to a custom link action. - *

- * Instead of using an {@link android.content.Intent#ACTION_VIEW} intent in the description of a - * content, this method will parse the {@link CharSequence} and replace all current web links - * with {@link ShareUtils#openUrlInBrowser(Context, String, boolean)}. - *

- * This method is required in order to intercept links and e.g. show a confirmation dialog - * before opening a web link. - * - * @param context the context to use - * @param chars the CharSequence to be parsed - * @param textView the TextView in which the converted CharSequence will be applied - * @return a disposable to be stored somewhere and disposed when activity/fragment is destroyed - */ - private static Disposable changeIntentsOfDescriptionLinks(final Context context, - final CharSequence chars, - final TextView textView) { - return Single.fromCallable(() -> { - final SpannableStringBuilder textBlockLinked = new SpannableStringBuilder(chars); - final URLSpan[] urls = textBlockLinked.getSpans(0, chars.length(), URLSpan.class); - - for (final URLSpan span : urls) { - final ClickableSpan clickableSpan = new ClickableSpan() { - public void onClick(@NonNull final View view) { - ShareUtils.openUrlInBrowser(context, span.getURL(), false); - } - }; - - textBlockLinked.setSpan(clickableSpan, textBlockLinked.getSpanStart(span), - textBlockLinked.getSpanEnd(span), textBlockLinked.getSpanFlags(span)); - textBlockLinked.removeSpan(span); - } - - return textBlockLinked; - }).subscribeOn(Schedulers.computation()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - textBlockLinked -> setTextViewCharSequence(textView, textBlockLinked), - throwable -> { - Log.e(TAG, "Unable to linkify text", throwable); - // this should never happen, but if it does, just fallback to it - setTextViewCharSequence(textView, chars); - }); - } - - private static void setTextViewCharSequence(final TextView textView, - final CharSequence charSequence) { - textView.setText(charSequence); - textView.setMovementMethod(LinkMovementMethod.getInstance()); - textView.setVisibility(View.VISIBLE); - } -} 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 e2b766bb03e..bc08e6197fb 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 static void addFileToZip(final ZipOutputStream outZip, final String file, } /** - * 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 static boolean extractFileFromZip(final String filePath, final String fil } } - 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/org/schabi/newpipe/util/external_communication/InternalUrlsHandler.java b/app/src/main/java/org/schabi/newpipe/util/external_communication/InternalUrlsHandler.java new file mode 100644 index 00000000000..39ec51ce41b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/external_communication/InternalUrlsHandler.java @@ -0,0 +1,154 @@ +package org.schabi.newpipe.util.external_communication; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.extractor.StreamingService; +import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.extractor.exceptions.ParsingException; +import org.schabi.newpipe.extractor.linkhandler.LinkHandlerFactory; +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.player.playqueue.PlayQueue; +import org.schabi.newpipe.player.playqueue.SinglePlayQueue; +import org.schabi.newpipe.util.ExtractorHelper; +import org.schabi.newpipe.util.NavigationHelper; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; +import io.reactivex.rxjava3.core.Single; +import io.reactivex.rxjava3.disposables.CompositeDisposable; +import io.reactivex.rxjava3.schedulers.Schedulers; + +public final class InternalUrlsHandler { + private static final Pattern AMPERSAND_TIMESTAMP_PATTERN = Pattern.compile("(.*)&t=(\\d+)"); + private static final Pattern HASHTAG_TIMESTAMP_PATTERN = + Pattern.compile("(.*)#timestamp=(\\d+)"); + + private InternalUrlsHandler() { + } + + /** + * Handle a YouTube timestamp comment URL in NewPipe. + *

+ * This method will check if the provided url is a YouTube comment description URL ({@code + * https://www.youtube.com/watch?v=}video_id{@code #timestamp=}time_in_seconds). If yes, the + * popup player will be opened when the user will click on the timestamp in the comment, + * at the time and for the video indicated in the timestamp. + * + * @param disposables a field of the Activity/Fragment class that calls this method + * @param context the context to use + * @param url the URL to check if it can be handled + * @return true if the URL can be handled by NewPipe, false if it cannot + */ + public static boolean handleUrlCommentsTimestamp(@NonNull final CompositeDisposable + disposables, + final Context context, + @NonNull final String url) { + return handleUrl(context, url, HASHTAG_TIMESTAMP_PATTERN, disposables); + } + + /** + * Handle a YouTube timestamp description URL in NewPipe. + *

+ * This method will check if the provided url is a YouTube timestamp description URL ({@code + * https://www.youtube.com/watch?v=}video_id{@code &t=}time_in_seconds). If yes, the popup + * player will be opened when the user will click on the timestamp in the video description, + * at the time and for the video indicated in the timestamp. + * + * @param disposables a field of the Activity/Fragment class that calls this method + * @param context the context to use + * @param url the URL to check if it can be handled + * @return true if the URL can be handled by NewPipe, false if it cannot + */ + public static boolean handleUrlDescriptionTimestamp(@NonNull final CompositeDisposable + disposables, + final Context context, + @NonNull final String url) { + return handleUrl(context, url, AMPERSAND_TIMESTAMP_PATTERN, disposables); + } + + /** + * Handle an URL in NewPipe. + *

+ * This method will check if the provided url can be handled in NewPipe or not. If this is a + * service URL with a timestamp, the popup player will be opened and true will be returned; + * else, false will be returned. + * + * @param context the context to use + * @param url the URL to check if it can be handled + * @param pattern the pattern to use + * @param disposables a field of the Activity/Fragment class that calls this method + * @return true if the URL can be handled by NewPipe, false if it cannot + */ + private static boolean handleUrl(final Context context, + @NonNull final String url, + @NonNull final Pattern pattern, + @NonNull final CompositeDisposable disposables) { + final Matcher matcher = pattern.matcher(url); + if (!matcher.matches()) { + return false; + } + final String matchedUrl = matcher.group(1); + final int seconds = Integer.parseInt(matcher.group(2)); + + final StreamingService service; + final StreamingService.LinkType linkType; + try { + service = NewPipe.getServiceByUrl(matchedUrl); + linkType = service.getLinkTypeByUrl(matchedUrl); + if (linkType == StreamingService.LinkType.NONE) { + return false; + } + } catch (final ExtractionException e) { + return false; + } + + if (linkType == StreamingService.LinkType.STREAM && seconds != -1) { + return playOnPopup(context, matchedUrl, service, seconds, disposables); + } else { + NavigationHelper.openRouterActivity(context, matchedUrl); + return true; + } + } + + /** + * Play a content in the floating player. + * + * @param context the context to be used + * @param url the URL of the content + * @param service the service of the content + * @param seconds the position in seconds at which the floating player will start + * @param disposables disposables created by the method are added here and their lifecycle + * should be handled by the calling class + * @return true if the playback of the content has successfully started or false if not + */ + public static boolean playOnPopup(final Context context, + final String url, + @NonNull final StreamingService service, + final int seconds, + @NonNull final CompositeDisposable disposables) { + final LinkHandlerFactory factory = service.getStreamLHFactory(); + final String cleanUrl; + + try { + cleanUrl = factory.getUrl(factory.getId(url)); + } catch (final ParsingException e) { + return false; + } + + final Single single + = ExtractorHelper.getStreamInfo(service.getServiceId(), cleanUrl, false); + disposables.add(single.subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(info -> { + final PlayQueue playQueue + = new SinglePlayQueue(info, seconds * 1000); + NavigationHelper.playOnPopupPlayer(context, playQueue, false); + })); + return true; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/KoreUtil.java b/app/src/main/java/org/schabi/newpipe/util/external_communication/KoreUtils.java similarity index 70% rename from app/src/main/java/org/schabi/newpipe/util/KoreUtil.java rename to app/src/main/java/org/schabi/newpipe/util/external_communication/KoreUtils.java index de6f3fa9a8d..6801f24ef2d 100644 --- a/app/src/main/java/org/schabi/newpipe/util/KoreUtil.java +++ b/app/src/main/java/org/schabi/newpipe/util/external_communication/KoreUtils.java @@ -1,28 +1,31 @@ -package org.schabi.newpipe.util; +package org.schabi.newpipe.util.external_communication; import android.content.Context; +import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; import androidx.preference.PreferenceManager; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.ServiceList; +import org.schabi.newpipe.util.NavigationHelper; -public final class KoreUtil { - private KoreUtil() { } +public final class KoreUtils { + private KoreUtils() { } public static boolean isServiceSupportedByKore(final int serviceId) { return (serviceId == ServiceList.YouTube.getServiceId() || serviceId == ServiceList.SoundCloud.getServiceId()); } - public static boolean shouldShowPlayWithKodi(final Context context, final int serviceId) { + public static boolean shouldShowPlayWithKodi(@NonNull final Context context, + final int serviceId) { return isServiceSupportedByKore(serviceId) && PreferenceManager.getDefaultSharedPreferences(context) .getBoolean(context.getString(R.string.show_play_with_kodi_key), false); } - public static void showInstallKoreDialog(final Context context) { + public static void showInstallKoreDialog(@NonNull final Context context) { final AlertDialog.Builder builder = new AlertDialog.Builder(context); builder.setMessage(R.string.kore_not_found) .setPositiveButton(R.string.install, (dialog, which) -> diff --git a/app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtils.java b/app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtils.java new file mode 100644 index 00000000000..e49cd6ea2d4 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtils.java @@ -0,0 +1,302 @@ +package org.schabi.newpipe.util.external_communication; + +import android.content.ActivityNotFoundException; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.net.Uri; +import android.os.Build; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; + +import org.schabi.newpipe.R; + +public final class ShareUtils { + private ShareUtils() { + } + + /** + * Open an Intent to install an app. + *

+ * This method tries to open the default app market with the package id passed as the + * second param (a system chooser will be opened if there are multiple markets and no default) + * and falls back to Google Play Store web URL if no app to handle the market scheme was found. + *

+ * It uses {@link #openIntentInApp(Context, Intent, boolean)} to open market scheme + * and {@link #openUrlInBrowser(Context, String, boolean)} to open Google Play Store + * web URL with false for the boolean param. + * + * @param context the context to use + * @param packageId the package id of the app to be installed + */ + public static void installApp(@NonNull final Context context, final String packageId) { + // Try market scheme + final boolean marketSchemeResult = openIntentInApp(context, new Intent(Intent.ACTION_VIEW, + Uri.parse("market://details?id=" + packageId)) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK), false); + if (!marketSchemeResult) { + // Fall back to Google Play Store Web URL (F-Droid can handle it) + openUrlInBrowser(context, + "https://play.google.com/store/apps/details?id=" + packageId, false); + } + } + + /** + * Open the url with the system default browser. + *

+ * If no browser is set as default, fallbacks to + * {@link #openAppChooser(Context, Intent, boolean)} + * + * @param context the context to use + * @param url the url to browse + * @param httpDefaultBrowserTest the boolean to set if the test for the default browser will be + * for HTTP protocol or for the created intent + * @return true if the URL can be opened or false if it cannot + */ + public static boolean openUrlInBrowser(@NonNull final Context context, + final String url, + final boolean httpDefaultBrowserTest) { + final String defaultPackageName; + final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + + if (httpDefaultBrowserTest) { + defaultPackageName = getDefaultAppPackageName(context, new Intent(Intent.ACTION_VIEW, + Uri.parse("http://")).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); + } else { + defaultPackageName = getDefaultAppPackageName(context, intent); + } + + if (defaultPackageName.equals("android")) { + // No browser set as default (doesn't work on some devices) + openAppChooser(context, intent, true); + } else { + if (defaultPackageName.isEmpty()) { + // No app installed to open a web url + Toast.makeText(context, R.string.no_app_to_open_intent, Toast.LENGTH_LONG).show(); + return false; + } else { + try { + intent.setPackage(defaultPackageName); + context.startActivity(intent); + } catch (final ActivityNotFoundException e) { + // Not a browser but an app chooser because of OEMs changes + intent.setPackage(null); + openAppChooser(context, intent, true); + } + } + } + + return true; + } + + /** + * Open the url with the system default browser. + *

+ * If no browser is set as default, fallbacks to + * {@link #openAppChooser(Context, Intent, boolean)} + *

+ * This calls {@link #openUrlInBrowser(Context, String, boolean)} with true + * for the boolean parameter + * + * @param context the context to use + * @param url the url to browse + * @return true if the URL can be opened or false if it cannot be + **/ + public static boolean openUrlInBrowser(@NonNull final Context context, final String url) { + return openUrlInBrowser(context, url, true); + } + + /** + * Open an intent with the system default app. + *

+ * The intent can be of every type, excepted a web intent for which + * {@link #openUrlInBrowser(Context, String, boolean)} should be used. + *

+ * If no app can open the intent, a toast with the message {@code No app on your device can + * open this} is shown. + * + * @param context the context to use + * @param intent the intent to open + * @param showToast a boolean to set if a toast is displayed to user when no app is installed + * to open the intent (true) or not (false) + * @return true if the intent can be opened or false if it cannot be + */ + public static boolean openIntentInApp(@NonNull final Context context, + @NonNull final Intent intent, + final boolean showToast) { + final String defaultPackageName = getDefaultAppPackageName(context, intent); + + if (defaultPackageName.isEmpty()) { + // No app installed to open the intent + if (showToast) { + Toast.makeText(context, R.string.no_app_to_open_intent, Toast.LENGTH_LONG) + .show(); + } + return false; + } else { + context.startActivity(intent); + } + + return true; + } + + /** + * Open the system chooser to launch an intent. + *

+ * This method opens an {@link android.content.Intent#ACTION_CHOOSER} of the intent putted + * as the intent param. If the setTitleChooser boolean is true, the string "Open with" will be + * set as the title of the system chooser. + * For Android P and higher, title for {@link android.content.Intent#ACTION_SEND} system + * choosers must be set on this intent, not on the + * {@link android.content.Intent#ACTION_CHOOSER} intent. + * + * @param context the context to use + * @param intent the intent to open + * @param setTitleChooser set the title "Open with" to the chooser if true, else not + */ + private static void openAppChooser(@NonNull final Context context, + @NonNull final Intent intent, + final boolean setTitleChooser) { + final Intent chooserIntent = new Intent(Intent.ACTION_CHOOSER); + chooserIntent.putExtra(Intent.EXTRA_INTENT, intent); + chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + if (setTitleChooser) { + chooserIntent.putExtra(Intent.EXTRA_TITLE, context.getString(R.string.open_with)); + } + + // Migrate any clip data and flags from the original intent. + final int permFlags; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + permFlags = intent.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION + | Intent.FLAG_GRANT_WRITE_URI_PERMISSION + | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION); + } else { + permFlags = intent.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION + | Intent.FLAG_GRANT_WRITE_URI_PERMISSION + | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION); + } + if (permFlags != 0) { + ClipData targetClipData = intent.getClipData(); + if (targetClipData == null && intent.getData() != null) { + final ClipData.Item item = new ClipData.Item(intent.getData()); + final String[] mimeTypes; + if (intent.getType() != null) { + mimeTypes = new String[] {intent.getType()}; + } else { + mimeTypes = new String[] {}; + } + targetClipData = new ClipData(null, mimeTypes, item); + } + if (targetClipData != null) { + chooserIntent.setClipData(targetClipData); + chooserIntent.addFlags(permFlags); + } + } + context.startActivity(chooserIntent); + } + + /** + * Get the default app package name. + *

+ * If no app is set as default, it will return "android" (not on some devices because some + * OEMs changed the app chooser). + *

+ * If no app is installed on user's device to handle the intent, it will return an empty string. + * + * @param context the context to use + * @param intent the intent to get default app + * @return the package name of the default app, an empty string if there's no app installed to + * handle the intent or the app chooser if there's no default + */ + private static String getDefaultAppPackageName(@NonNull final Context context, + @NonNull final Intent intent) { + final ResolveInfo resolveInfo = context.getPackageManager().resolveActivity(intent, + PackageManager.MATCH_DEFAULT_ONLY); + + if (resolveInfo == null) { + return ""; + } else { + return resolveInfo.activityInfo.packageName; + } + } + + /** + * Open the android share sheet to share a content. + * + * For Android 10+ users, a content preview is shown, which includes the title of the shared + * content. + * Support sharing the image of the content needs to done, if possible. + * + * @param context the context to use + * @param title the title of the content + * @param content the content to share + * @param imagePreviewUrl the image of the subject + */ + public static void shareText(@NonNull final Context context, + @NonNull final String title, + final String content, + final String imagePreviewUrl) { + final Intent shareIntent = new Intent(Intent.ACTION_SEND); + shareIntent.setType("text/plain"); + shareIntent.putExtra(Intent.EXTRA_TEXT, content); + if (!title.isEmpty()) { + shareIntent.putExtra(Intent.EXTRA_TITLE, title); + } + + /* TODO: add the image of the content to Android share sheet with setClipData after + generating a content URI of this image, then use ClipData.newUri(the content resolver, + null, the content URI) and set the ClipData to the share intent with + shareIntent.setClipData(generated ClipData). + if (!imagePreviewUrl.isEmpty()) { + //shareIntent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + }*/ + + openAppChooser(context, shareIntent, false); + } + + /** + * Open the android share sheet to share a content. + * + * For Android 10+ users, a content preview is shown, which includes the title of the shared + * content. + *

+ * This calls {@link #shareText(Context, String, String, String)} with an empty string for the + * imagePreviewUrl parameter. + * + * @param context the context to use + * @param title the title of the content + * @param content the content to share + */ + public static void shareText(@NonNull final Context context, + @NonNull final String title, + final String content) { + shareText(context, title, content, ""); + } + + /** + * Copy the text to clipboard, and indicate to the user whether the operation was completed + * successfully using a Toast. + * + * @param context the context to use + * @param text the text to copy + */ + public static void copyToClipboard(@NonNull final Context context, final String text) { + final ClipboardManager clipboardManager = + ContextCompat.getSystemService(context, ClipboardManager.class); + + if (clipboardManager == null) { + Toast.makeText(context, R.string.permission_denied, Toast.LENGTH_LONG).show(); + return; + } + + clipboardManager.setPrimaryClip(ClipData.newPlainText(null, text)); + Toast.makeText(context, R.string.msg_copied, Toast.LENGTH_SHORT).show(); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/external_communication/TextLinkifier.java b/app/src/main/java/org/schabi/newpipe/util/external_communication/TextLinkifier.java new file mode 100644 index 00000000000..76da096094b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/external_communication/TextLinkifier.java @@ -0,0 +1,287 @@ +package org.schabi.newpipe.util.external_communication; + +import android.content.Context; +import android.text.SpannableStringBuilder; +import android.text.method.LinkMovementMethod; +import android.text.style.ClickableSpan; +import android.text.style.URLSpan; +import android.text.util.Linkify; +import android.util.Log; +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.text.HtmlCompat; + +import org.schabi.newpipe.extractor.Info; +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.util.NavigationHelper; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import io.noties.markwon.Markwon; +import io.noties.markwon.linkify.LinkifyPlugin; +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; +import io.reactivex.rxjava3.core.Single; +import io.reactivex.rxjava3.disposables.CompositeDisposable; +import io.reactivex.rxjava3.schedulers.Schedulers; + +import static org.schabi.newpipe.util.external_communication.InternalUrlsHandler.playOnPopup; + +public final class TextLinkifier { + public static final String TAG = TextLinkifier.class.getSimpleName(); + private static final Pattern HASHTAGS_PATTERN = Pattern.compile("(#[A-Za-z0-9_]+)"); + private static final Pattern TIMESTAMPS_PATTERN = Pattern.compile( + "(?:^|(?!:)\\W)(?:([0-5]?[0-9]):)?([0-5]?[0-9]):([0-5][0-9])(?=$|(?!:)\\W)"); + + private TextLinkifier() { + } + + /** + * Create web links for contents with an HTML description. + *

+ * This will call {@link TextLinkifier#changeIntentsOfDescriptionLinks(TextView, CharSequence, + * Info, CompositeDisposable)} after having linked the URLs with + * {@link HtmlCompat#fromHtml(String, int)}. + * + * @param textView the TextView to set the htmlBlock linked + * @param htmlBlock the htmlBlock to be linked + * @param htmlCompatFlag the int flag to be set when {@link HtmlCompat#fromHtml(String, int)} + * will be called + * @param relatedInfo if given, handle timestamps to open the stream in the popup player at + * the specific time, and hashtags to search for the term in the correct + * service + * @param disposables disposables created by the method are added here and their lifecycle + * should be handled by the calling class + */ + public static void createLinksFromHtmlBlock(@NonNull final TextView textView, + final String htmlBlock, + final int htmlCompatFlag, + @Nullable final Info relatedInfo, + final CompositeDisposable disposables) { + changeIntentsOfDescriptionLinks( + textView, HtmlCompat.fromHtml(htmlBlock, htmlCompatFlag), relatedInfo, disposables); + } + + /** + * Create web links for contents with a plain text description. + *

+ * This will call {@link TextLinkifier#changeIntentsOfDescriptionLinks(TextView, CharSequence, + * Info, CompositeDisposable)} after having linked the URLs with + * {@link TextView#setAutoLinkMask(int)} and + * {@link TextView#setText(CharSequence, TextView.BufferType)}. + * + * @param textView the TextView to set the plain text block linked + * @param plainTextBlock the block of plain text to be linked + * @param relatedInfo if given, handle timestamps to open the stream in the popup player at + * the specific time, and hashtags to search for the term in the correct + * service + * @param disposables disposables created by the method are added here and their lifecycle + * should be handled by the calling class + */ + public static void createLinksFromPlainText(@NonNull final TextView textView, + final String plainTextBlock, + @Nullable final Info relatedInfo, + final CompositeDisposable disposables) { + textView.setAutoLinkMask(Linkify.WEB_URLS); + textView.setText(plainTextBlock, TextView.BufferType.SPANNABLE); + changeIntentsOfDescriptionLinks(textView, textView.getText(), relatedInfo, disposables); + } + + /** + * Create web links for contents with a markdown description. + *

+ * This will call {@link TextLinkifier#changeIntentsOfDescriptionLinks(TextView, CharSequence, + * Info, CompositeDisposable)} after creating an {@link Markwon} object and using + * {@link Markwon#setMarkdown(TextView, String)}. + * + * @param textView the TextView to set the plain text block linked + * @param markdownBlock the block of markdown text to be linked + * @param relatedInfo if given, handle timestamps to open the stream in the popup player at + * the specific time, and hashtags to search for the term in the correct + * @param disposables disposables created by the method are added here and their lifecycle + * should be handled by the calling class + */ + public static void createLinksFromMarkdownText(@NonNull final TextView textView, + final String markdownBlock, + @Nullable final Info relatedInfo, + final CompositeDisposable disposables) { + final Markwon markwon = Markwon.builder(textView.getContext()) + .usePlugin(LinkifyPlugin.create()).build(); + changeIntentsOfDescriptionLinks(textView, markwon.toMarkdown(markdownBlock), relatedInfo, + disposables); + } + + /** + * Add click listeners which opens a search on hashtags in a plain text. + *

+ * This method finds all timestamps in the {@link SpannableStringBuilder} of the description + * using a regular expression, adds for each a {@link ClickableSpan} which opens + * {@link NavigationHelper#openSearch(Context, int, String)} and makes a search on the hashtag, + * in the service of the content. + * + * @param context the context to use + * @param spannableDescription the SpannableStringBuilder with the text of the + * content description + * @param relatedInfo used to search for the term in the correct service + */ + private static void addClickListenersOnHashtags(final Context context, + @NonNull final SpannableStringBuilder + spannableDescription, + final Info relatedInfo) { + final String descriptionText = spannableDescription.toString(); + final Matcher hashtagsMatches = HASHTAGS_PATTERN.matcher(descriptionText); + + while (hashtagsMatches.find()) { + final int hashtagStart = hashtagsMatches.start(1); + final int hashtagEnd = hashtagsMatches.end(1); + final String parsedHashtag = descriptionText.substring(hashtagStart, hashtagEnd); + + // don't add a ClickableSpan if there is already one, which should be a part of an URL, + // already parsed before + if (spannableDescription.getSpans(hashtagStart, hashtagEnd, + ClickableSpan.class).length == 0) { + spannableDescription.setSpan(new ClickableSpan() { + @Override + public void onClick(@NonNull final View view) { + NavigationHelper.openSearch(context, relatedInfo.getServiceId(), + parsedHashtag); + } + }, hashtagStart, hashtagEnd, 0); + } + } + } + + /** + * Add click listeners which opens the popup player on timestamps in a plain text. + *

+ * This method finds all timestamps in the {@link SpannableStringBuilder} of the description + * using a regular expression, adds for each a {@link ClickableSpan} which opens the popup + * player at the time indicated in the timestamps. + * + * @param context the context to use + * @param spannableDescription the SpannableStringBuilder with the text of the + * content description + * @param relatedInfo what to open in the popup player when timestamps are clicked + * @param disposables disposables created by the method are added here and their + * lifecycle should be handled by the calling class + */ + private static void addClickListenersOnTimestamps(final Context context, + @NonNull final SpannableStringBuilder + spannableDescription, + final Info relatedInfo, + final CompositeDisposable disposables) { + final String descriptionText = spannableDescription.toString(); + final Matcher timestampsMatches = TIMESTAMPS_PATTERN.matcher(descriptionText); + + while (timestampsMatches.find()) { + final int timestampStart = timestampsMatches.start(2); + final int timestampEnd = timestampsMatches.end(3); + final String parsedTimestamp = descriptionText.substring(timestampStart, timestampEnd); + final String[] timestampParts = parsedTimestamp.split(":"); + + final int seconds; + if (timestampParts.length == 3) { // timestamp format: XX:XX:XX + seconds = Integer.parseInt(timestampParts[0]) * 3600 // hours + + Integer.parseInt(timestampParts[1]) * 60 // minutes + + Integer.parseInt(timestampParts[2]); // seconds + } else if (timestampParts.length == 2) { // timestamp format: XX:XX + seconds = Integer.parseInt(timestampParts[0]) * 60 // minutes + + Integer.parseInt(timestampParts[1]); // seconds + } else { + continue; + } + + spannableDescription.setSpan(new ClickableSpan() { + @Override + public void onClick(@NonNull final View view) { + playOnPopup(context, relatedInfo.getUrl(), relatedInfo.getService(), seconds, + disposables); + } + }, timestampStart, timestampEnd, 0); + } + } + + /** + * Change links generated by libraries in the description of a content to a custom link action + * and add click listeners on timestamps in this description. + *

+ * Instead of using an {@link android.content.Intent#ACTION_VIEW} intent in the description of + * a content, this method will parse the {@link CharSequence} and replace all current web links + * with {@link ShareUtils#openUrlInBrowser(Context, String, boolean)}. + * This method will also add click listeners on timestamps in this description, which will play + * the content in the popup player at the time indicated in the timestamp, by using + * {@link TextLinkifier#addClickListenersOnTimestamps(Context, SpannableStringBuilder, Info, + * CompositeDisposable)} method and click listeners on hashtags, by using + * {@link TextLinkifier#addClickListenersOnHashtags(Context, SpannableStringBuilder, Info)}, + * which will open a search on the current service with the hashtag. + *

+ * This method is required in order to intercept links and e.g. show a confirmation dialog + * before opening a web link. + * + * @param textView the TextView in which the converted CharSequence will be applied + * @param chars the CharSequence to be parsed + * @param relatedInfo if given, handle timestamps to open the stream in the popup player at + * the specific time, and hashtags to search for the term in the correct + * service + * @param disposables disposables created by the method are added here and their lifecycle + * should be handled by the calling class + */ + private static void changeIntentsOfDescriptionLinks(final TextView textView, + final CharSequence chars, + @Nullable final Info relatedInfo, + final CompositeDisposable disposables) { + disposables.add(Single.fromCallable(() -> { + final Context context = textView.getContext(); + + // add custom click actions on web links + final SpannableStringBuilder textBlockLinked = new SpannableStringBuilder(chars); + final URLSpan[] urls = textBlockLinked.getSpans(0, chars.length(), URLSpan.class); + + for (final URLSpan span : urls) { + final String url = span.getURL(); + final ClickableSpan clickableSpan = new ClickableSpan() { + public void onClick(@NonNull final View view) { + if (!InternalUrlsHandler.handleUrlDescriptionTimestamp( + new CompositeDisposable(), context, url)) { + ShareUtils.openUrlInBrowser(context, url, false); + } + } + }; + + textBlockLinked.setSpan(clickableSpan, textBlockLinked.getSpanStart(span), + textBlockLinked.getSpanEnd(span), textBlockLinked.getSpanFlags(span)); + textBlockLinked.removeSpan(span); + } + + // add click actions on plain text timestamps only for description of contents, + // unneeded for meta-info or other TextViews + if (relatedInfo != null) { + if (relatedInfo instanceof StreamInfo) { + addClickListenersOnTimestamps(context, textBlockLinked, relatedInfo, + disposables); + } + addClickListenersOnHashtags(context, textBlockLinked, relatedInfo); + } + + return textBlockLinked; + }).subscribeOn(Schedulers.computation()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + textBlockLinked -> setTextViewCharSequence(textView, textBlockLinked), + throwable -> { + Log.e(TAG, "Unable to linkify text", throwable); + // this should never happen, but if it does, just fallback to it + setTextViewCharSequence(textView, chars); + })); + } + + private static void setTextViewCharSequence(@NonNull final TextView textView, + final CharSequence charSequence) { + textView.setText(charSequence); + textView.setMovementMethod(LinkMovementMethod.getInstance()); + textView.setVisibility(View.VISIBLE); + } +} 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 2b3faa3e050..628058f5502 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 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 ecb0eaebd5f..77b9c1e3397 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 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 boolean delete() { @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 15c45c6fd31..704385212ab 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 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 0d6320b53e4..00000000000 --- 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 ad64042f179..00000000000 --- 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 8359fce9aa8..a2811e72eb4 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.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 @@ private static boolean testDir(@Nullable File dir) { } /** - * 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 @@ private DownloadMission getPendingMission(StoredFileHelper storage) { } /** - * 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 568c3497add..52c28828d5d 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 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 41a254b4976..e06485fdf9f 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 @@ -2,7 +2,6 @@ import android.annotation.SuppressLint; import android.app.NotificationManager; -import android.content.ActivityNotFoundException; import android.content.Context; import android.content.Intent; import android.graphics.Color; @@ -45,7 +44,7 @@ import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.ShareUtils; +import org.schabi.newpipe.util.external_communication.ShareUtils; import java.io.File; import java.net.URI; @@ -61,7 +60,7 @@ 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; @@ -348,10 +347,8 @@ private void viewWithFileProvider(Mission mission) { if (BuildConfig.DEBUG) Log.v(TAG, "Mime: " + mimeType + " package: " + BuildConfig.APPLICATION_ID + ".provider"); - final Uri uri = resolveShareableUri(mission); - Intent intent = new Intent(Intent.ACTION_VIEW); - intent.setDataAndType(uri, mimeType); + intent.setDataAndType(resolveShareableUri(mission), mimeType); intent.addFlags(FLAG_GRANT_READ_URI_PERMISSION); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { @@ -361,10 +358,8 @@ private void viewWithFileProvider(Mission mission) { intent.addFlags(FLAG_ACTIVITY_NEW_TASK); } - //mContext.grantUriPermission(packageName, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION); - if (intent.resolveActivity(mContext.getPackageManager()) != null) { - ShareUtils.openIntentInApp(mContext, intent); + ShareUtils.openIntentInApp(mContext, intent, false); } else { Toast.makeText(mContext, R.string.toast_no_player, Toast.LENGTH_LONG).show(); } @@ -377,19 +372,18 @@ private void shareFile(Mission mission) { shareIntent.setType(resolveMimeType(mission)); shareIntent.putExtra(Intent.EXTRA_STREAM, resolveShareableUri(mission)); shareIntent.addFlags(FLAG_GRANT_READ_URI_PERMISSION); + final Intent intent = new Intent(Intent.ACTION_CHOOSER); intent.putExtra(Intent.EXTRA_INTENT, shareIntent); - intent.putExtra(Intent.EXTRA_TITLE, mContext.getString(R.string.share_dialog_title)); + // unneeded to set a title to the chooser on Android P and higher because the system + // ignores this title on these versions + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.O_MR1) { + intent.putExtra(Intent.EXTRA_TITLE, mContext.getString(R.string.share_dialog_title)); + } intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.addFlags(FLAG_GRANT_READ_URI_PERMISSION); - try { - intent.setPackage("android"); - mContext.startActivity(intent); - } catch (final ActivityNotFoundException e) { - // falling back to OEM chooser if Android's system chooser was removed by the OEM - intent.setPackage(null); - mContext.startActivity(intent); - } + mContext.startActivity(intent); } /** 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 c83eec819af..793d147b595 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 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 @@ private void setAdapterButtons() { 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 ab584f0e6c7..9e6787d5d8a 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.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/drawable-night/ic_visibility_off.xml b/app/src/main/res/drawable-night/ic_visibility_off.xml new file mode 100644 index 00000000000..689f3f47c14 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_visibility_off.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_visibility_on.xml b/app/src/main/res/drawable-night/ic_visibility_on.xml new file mode 100644 index 00000000000..e02f1d19120 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_visibility_on.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_visibility_off.xml b/app/src/main/res/drawable/ic_visibility_off.xml new file mode 100644 index 00000000000..e0b170300a0 --- /dev/null +++ b/app/src/main/res/drawable/ic_visibility_off.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_visibility_on.xml b/app/src/main/res/drawable/ic_visibility_on.xml new file mode 100644 index 00000000000..6c95a5d2921 --- /dev/null +++ b/app/src/main/res/drawable/ic_visibility_on.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/item_in_history_indicator_background.xml b/app/src/main/res/drawable/item_in_history_indicator_background.xml new file mode 100644 index 00000000000..1c3a9a56b3f --- /dev/null +++ b/app/src/main/res/drawable/item_in_history_indicator_background.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/src/main/res/layout-large-land/player.xml b/app/src/main/res/layout-large-land/player.xml index 4283cb9db87..e3377f8315e 100644 --- a/app/src/main/res/layout-large-land/player.xml +++ b/app/src/main/res/layout-large-land/player.xml @@ -531,7 +531,7 @@ app:tint="@color/white" app:srcCompat="@drawable/ic_close" /> - + tools:text="Account terminated" /> + + + + +