Merge branch 'master' into sponsorblock

# Conflicts:
#	app/src/main/AndroidManifest.xml
#	app/src/main/java/org/schabi/newpipe/CheckForNewAppVersion.java
#	app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java
#	app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java
#	app/src/main/java/org/schabi/newpipe/player/Player.java
#	app/src/main/java/us/shandian/giga/get/Mission.java
#	app/src/main/res/values-so/strings.xml
#	app/src/main/res/values/strings.xml
This commit is contained in:
polymorphicshade 2021-07-24 18:15:04 -06:00
commit 146601da40
247 changed files with 5934 additions and 3330 deletions

View file

@ -3,9 +3,9 @@ NewPipe contribution guidelines
## Crash reporting ## 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. 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 ## 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 ## 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. ### Guidelines
* 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. * 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.
* Do not bring non-free software (e.g. binary blobs) into the project. Make sure you do not introduce Google
libraries.
* Stick to [F-Droid contribution guidelines](https://f-droid.org/wiki/page/Inclusion_Policy). * 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. * 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.
* 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. ### Before starting development
* 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.
* 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. * 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. * 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 ## 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)! * 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). * 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.
* Post suggestions, changes, ideas etc. on GitHub or IRC. * You can post your suggestions, changes, ideas etc. on either GitHub or IRC.

View file

@ -33,7 +33,7 @@ Oh no, a bug! It happens. Thanks for reporting an issue with NewPipe. To make it
### Actual behaviour ### Actual behavior
<!-- Tell us what happens with the steps given above. --> <!-- Tell us what happens with the steps given above. -->

24
.github/ISSUE_TEMPLATE/question.md vendored Normal file
View file

@ -0,0 +1,24 @@
---
name: Question
about: Ask about anything NewPipe-related
labels: question
assignees: ''
---
<!-- IF YOU DON'T FILL IN THE TEMPLATE PROPERLY, YOUR ISSUE IS LIABLE TO BE CLOSED. If you feel tired/lazy right now, open your issue some other time. We'll wait. -->
<!-- The comments between these brackets won't show up in the submitted issue (as you can see in the Preview). -->
### Checklist
<!-- This checklist is COMPULSORY. The first box has been checked for you to show you how it is done. -->
- [x] I checked, but didn't find any duplicates (open OR closed) of this issue in the repo. <!-- Seriously, check. O_O (If there's already an issue but you'd like to see if something changed, just make a comment on the issue instead of opening a new one.) -->
- [ ] 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
<!-- Add any other context, like screenshots or links, about the question here.
Example: *Here's a photo of my cat!* -->

View file

@ -12,18 +12,23 @@
- create clones - create clones
- take over the world - take over the world
#### Before/After Screenshots/Screen Record
<!-- If your PR changes the app's UI in any way, please include screenshots or a video showing exactly what changed, so that developers and users can pinpoint it easily. Delete this if it doesn't apply to your PR.-->
- Before:
- After:
#### Fixes the following issue(s) #### Fixes the following issue(s)
<!-- Prefix issues with "Fixes" so that GitHub closes them when the PR is merged (note that each "Fixes #" should be in its own item). Also add any other relevant links. --> <!-- Prefix issues with "Fixes" so that GitHub closes them when the PR is merged (note that each "Fixes #" should be in its own item). Also add any other relevant links. -->
- Fixes # - Fixes #
#### Relies on the following changes #### Relies on the following changes
<!-- Delete this if it doesn't apply to you. --> <!-- Delete this if it doesn't apply to your PR. -->
- -
#### APK testing #### APK testing
<!-- Use a new, meaningfully named branch. The name is used as a suffix for the app ID to allow installing and testing multiple versions of NewPipe, e.g. "commentfix", if your PR implements a bugfix for comments. (No names like "patch-0" and "feature-1".) --> <!-- Use a new, meaningfully named branch. The name is used as a suffix for the app ID to allow installing and testing multiple versions of NewPipe, e.g. "commentfix", if your PR implements a bugfix for comments. (No names like "patch-0" and "feature-1".) -->
<!-- Remove the following line if you directly link the APK created by the CI pipeline. Directly linking is preferred if you need to let users test.--> <!-- Remove the following line if you directly link the APK created by the CI pipeline. Directly linking is preferred if you need to let users test.-->
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 #### Due diligence
- [ ] I read the [contribution guidelines](https://github.com/TeamNewPipe/NewPipe/blob/HEAD/.github/CONTRIBUTING.md). - [ ] I read the [contribution guidelines](https://github.com/TeamNewPipe/NewPipe/blob/HEAD/.github/CONTRIBUTING.md).

View file

@ -43,32 +43,37 @@ jobs:
with: with:
name: app name: app
path: app/build/outputs/apk/debug/*.apk 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 # Disabled until emulator works again. see https://github.com/TeamNewPipe/NewPipe/pull/6560
uses: actions/setup-java@v2 # test-android:
with: # macos has hardware acceleration. See android-emulator-runner action
java-version: 8 # runs-on: macos-latest
distribution: "adopt" # 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: # sonar:
# runs-on: ubuntu-latest # runs-on: ubuntu-latest
# steps: # steps:

View file

@ -17,8 +17,8 @@ android {
resValue "string", "app_name", "NewPipe" resValue "string", "app_name", "NewPipe"
minSdkVersion 19 minSdkVersion 19
targetSdkVersion 29 targetSdkVersion 29
versionCode 971 versionCode 973
versionName "0.21.5" versionName "0.21.7"
multiDexEnabled true multiDexEnabled true
@ -102,7 +102,7 @@ ext {
checkstyleVersion = '8.38' checkstyleVersion = '8.38'
androidxLifecycleVersion = '2.2.0' androidxLifecycleVersion = '2.2.0'
androidxRoomVersion = '2.3.0-alpha03' androidxRoomVersion = '2.3.0'
icepickVersion = '3.2.0' icepickVersion = '3.2.0'
exoPlayerVersion = '2.12.3' exoPlayerVersion = '2.12.3'
@ -182,8 +182,11 @@ dependencies {
/** NewPipe libraries **/ /** NewPipe libraries **/
// You can use a local version by uncommenting a few lines in settings.gradle // 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:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751'
implementation 'com.github.TeamNewPipe:NewPipeExtractor:v0.21.4' implementation 'com.github.TeamNewPipe:NewPipeExtractor:v0.21.6'
/** Checkstyle **/ /** Checkstyle **/
checkstyle "com.puppycrawl.tools:checkstyle:${checkstyleVersion}" checkstyle "com.puppycrawl.tools:checkstyle:${checkstyleVersion}"
@ -198,6 +201,7 @@ dependencies {
implementation 'androidx.constraintlayout:constraintlayout:2.0.4' implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
implementation 'androidx.core:core-ktx:1.3.2' implementation 'androidx.core:core-ktx:1.3.2'
implementation 'androidx.documentfile:documentfile:1.0.1' implementation 'androidx.documentfile:documentfile:1.0.1'
implementation 'androidx.fragment:fragment-ktx:1.3.5'
implementation "androidx.lifecycle:lifecycle-livedata:${androidxLifecycleVersion}" implementation "androidx.lifecycle:lifecycle-livedata:${androidxLifecycleVersion}"
implementation "androidx.lifecycle:lifecycle-viewmodel:${androidxLifecycleVersion}" implementation "androidx.lifecycle:lifecycle-viewmodel:${androidxLifecycleVersion}"
implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.0.0' implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.0.0'

View file

@ -22,9 +22,8 @@
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:logo="@mipmap/ic_launcher" android:logo="@mipmap/ic_launcher"
android:requestLegacyExternalStorage="true"
android:resizeableActivity="true"
android:theme="@style/OpeningTheme" android:theme="@style/OpeningTheme"
android:resizeableActivity="true"
tools:ignore="AllowBackup"> tools:ignore="AllowBackup">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
@ -229,11 +228,10 @@
<data android:host="invidious.snopyta.org" /> <data android:host="invidious.snopyta.org" />
<data android:host="yewtu.be" /> <data android:host="yewtu.be" />
<data android:host="tube.connect.cafe" /> <data android:host="tube.connect.cafe" />
<data android:host="invidious.zapashcanon.fr" />
<data android:host="invidious.kavin.rocks" /> <data android:host="invidious.kavin.rocks" />
<data android:host="invidious.tube" /> <data android:host="invidious-us.kavin.rocks" />
<data android:host="piped.kavin.rocks" />
<data android:host="invidious.site" /> <data android:host="invidious.site" />
<data android:host="invidious.xyz" />
<data android:host="vid.mint.lgbt" /> <data android:host="vid.mint.lgbt" />
<data android:host="invidiou.site" /> <data android:host="invidiou.site" />
<data android:host="invidious.fdn.fr" /> <data android:host="invidious.fdn.fr" />
@ -241,6 +239,15 @@
<data android:host="invidious.zee.li" /> <data android:host="invidious.zee.li" />
<data android:host="vid.puffyan.us" /> <data android:host="vid.puffyan.us" />
<data android:host="ytprivate.com" /> <data android:host="ytprivate.com" />
<data android:host="invidious.namazso.eu" />
<data android:host="invidious.silkky.cloud" />
<data android:host="invidious.exonip.de" />
<data android:host="inv.riverside.rocks" />
<data android:host="invidious.blamefran.net" />
<data android:host="invidious.moomoo.me" />
<data android:host="ytb.trom.tf" />
<data android:host="yt.cyberhost.uk" />
<data android:host="y.com.cm" />
<data android:pathPrefix="/" /> <data android:pathPrefix="/" />
</intent-filter> </intent-filter>

View file

@ -63,8 +63,9 @@ public final class FlingBehavior extends AppBarLayout.Behavior {
return consumed == dy; return consumed == dy;
} }
public boolean onInterceptTouchEvent(final CoordinatorLayout parent, final AppBarLayout child, public boolean onInterceptTouchEvent(@NonNull final CoordinatorLayout parent,
final MotionEvent ev) { @NonNull final AppBarLayout child,
@NonNull final MotionEvent ev) {
for (final Integer element : skipInterceptionOfElements) { for (final Integer element : skipInterceptionOfElements) {
final View view = child.findViewById(element); final View view = child.findViewById(element);
if (view != null) { if (view != null) {

View file

@ -27,7 +27,7 @@ import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.downloader.Downloader; import org.schabi.newpipe.extractor.downloader.Downloader;
import org.schabi.newpipe.ktx.ExceptionUtils; 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.Localization;
import org.schabi.newpipe.util.ServiceHelper; import org.schabi.newpipe.util.ServiceHelper;
import org.schabi.newpipe.util.StateSaver; import org.schabi.newpipe.util.StateSaver;
@ -91,7 +91,7 @@ public class App extends MultiDexApplication {
app = this; app = this;
// Initialize settings first because others inits can use its values // Initialize settings first because others inits can use its values
SettingsActivity.initSettings(this); NewPipeSettings.initSettings(this);
NewPipe.init(getDownloader(), NewPipe.init(getDownloader(),
Localization.getPreferredLocalization(this), Localization.getPreferredLocalization(this),

View file

@ -6,6 +6,8 @@ import android.content.Intent;
import android.os.Build; import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import org.schabi.newpipe.util.NavigationHelper;
/* /*
* Copyright (C) Hans-Christoph Steiner 2016 <hans@eds.org> * Copyright (C) Hans-Christoph Steiner 2016 <hans@eds.org>
* ExitActivity.java is part of NewPipe. * ExitActivity.java is part of NewPipe.
@ -48,6 +50,6 @@ public class ExitActivity extends Activity {
finish(); finish();
} }
System.exit(0); NavigationHelper.restartApp(this);
} }
} }

View file

@ -603,6 +603,7 @@ public class MainActivity extends AppCompatActivity {
public void onRequestPermissionsResult(final int requestCode, public void onRequestPermissionsResult(final int requestCode,
@NonNull final String[] permissions, @NonNull final String[] permissions,
@NonNull final int[] grantResults) { @NonNull final int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
for (final int i : grantResults) { for (final int i : grantResults) {
if (i == PackageManager.PERMISSION_DENIED) { if (i == PackageManager.PERMISSION_DENIED) {
return; return;

View file

@ -51,4 +51,15 @@ public final class NewPipeDatabase {
throw new RuntimeException("Checkpoint was blocked from completing"); 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;
}
}
}
}
} }

View file

@ -69,7 +69,7 @@ import org.schabi.newpipe.util.ExtractorHelper;
import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.ListHelper;
import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.PermissionHelper; 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.ThemeHelper;
import org.schabi.newpipe.util.urlfinder.UrlFinder; import org.schabi.newpipe.util.urlfinder.UrlFinder;
import org.schabi.newpipe.views.FocusOverlayView; import org.schabi.newpipe.views.FocusOverlayView;
@ -107,6 +107,7 @@ public class RouterActivity extends AppCompatActivity {
protected String currentUrl; protected String currentUrl;
private StreamingService currentService; private StreamingService currentService;
private boolean selectionIsDownload = false; private boolean selectionIsDownload = false;
private AlertDialog alertDialogChoice = null;
@Override @Override
protected void onCreate(final Bundle savedInstanceState) { protected void onCreate(final Bundle savedInstanceState) {
@ -126,6 +127,15 @@ public class RouterActivity extends AppCompatActivity {
? R.style.RouterActivityThemeLight : R.style.RouterActivityThemeDark); ? 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 @Override
protected void onSaveInstanceState(@NonNull final Bundle outState) { protected void onSaveInstanceState(@NonNull final Bundle outState) {
super.onSaveInstanceState(outState); super.onSaveInstanceState(outState);
@ -333,7 +343,7 @@ public class RouterActivity extends AppCompatActivity {
} }
}; };
final AlertDialog alertDialog = new AlertDialog.Builder(themeWrapperContext) alertDialogChoice = new AlertDialog.Builder(themeWrapperContext)
.setTitle(R.string.preferred_open_action_share_menu_title) .setTitle(R.string.preferred_open_action_share_menu_title)
.setView(radioGroup) .setView(radioGroup)
.setCancelable(true) .setCancelable(true)
@ -347,12 +357,12 @@ public class RouterActivity extends AppCompatActivity {
.create(); .create();
//noinspection CodeBlock2Expr //noinspection CodeBlock2Expr
alertDialog.setOnShowListener(dialog -> { alertDialogChoice.setOnShowListener(dialog -> {
setDialogButtonsState(alertDialog, radioGroup.getCheckedRadioButtonId() != -1); setDialogButtonsState(alertDialogChoice, radioGroup.getCheckedRadioButtonId() != -1);
}); });
radioGroup.setOnCheckedChangeListener((group, checkedId) -> radioGroup.setOnCheckedChangeListener((group, checkedId) ->
setDialogButtonsState(alertDialog, true)); setDialogButtonsState(alertDialogChoice, true));
final View.OnClickListener radioButtonsClickListener = v -> { final View.OnClickListener radioButtonsClickListener = v -> {
final int indexOfChild = radioGroup.indexOfChild(v); final int indexOfChild = radioGroup.indexOfChild(v);
if (indexOfChild == -1) { if (indexOfChild == -1) {
@ -402,10 +412,10 @@ public class RouterActivity extends AppCompatActivity {
} }
selectedPreviously = selectedRadioPosition; selectedPreviously = selectedRadioPosition;
alertDialog.show(); alertDialogChoice.show();
if (DeviceUtils.isTv(this)) { if (DeviceUtils.isTv(this)) {
FocusOverlayView.setupFocusObserver(alertDialog); FocusOverlayView.setupFocusObserver(alertDialogChoice);
} }
} }
@ -579,9 +589,9 @@ public class RouterActivity extends AppCompatActivity {
downloadDialog.setVideoStreams(sortedVideoStreams); downloadDialog.setVideoStreams(sortedVideoStreams);
downloadDialog.setAudioStreams(result.getAudioStreams()); downloadDialog.setAudioStreams(result.getAudioStreams());
downloadDialog.setSelectedVideoStream(selectedVideoStreamIndex); downloadDialog.setSelectedVideoStream(selectedVideoStreamIndex);
downloadDialog.setOnDismissListener(dialog -> finish());
downloadDialog.show(fm, "downloadDialog"); downloadDialog.show(fm, "downloadDialog");
fm.executePendingTransactions(); fm.executePendingTransactions();
downloadDialog.requireDialog().setOnDismissListener(dialog -> finish());
}, throwable -> }, throwable ->
showUnsupportedUrlDialog(currentUrl))); showUnsupportedUrlDialog(currentUrl)));
} }
@ -590,6 +600,7 @@ public class RouterActivity extends AppCompatActivity {
public void onRequestPermissionsResult(final int requestCode, public void onRequestPermissionsResult(final int requestCode,
@NonNull final String[] permissions, @NonNull final String[] permissions,
@NonNull final int[] grantResults) { @NonNull final int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
for (final int i : grantResults) { for (final int i : grantResults) {
if (i == PackageManager.PERMISSION_DENIED) { if (i == PackageManager.PERMISSION_DENIED) {
finish(); finish();

View file

@ -17,8 +17,8 @@ import org.schabi.newpipe.R
import org.schabi.newpipe.databinding.ActivityAboutBinding import org.schabi.newpipe.databinding.ActivityAboutBinding
import org.schabi.newpipe.databinding.FragmentAboutBinding import org.schabi.newpipe.databinding.FragmentAboutBinding
import org.schabi.newpipe.util.Localization import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.ShareUtils
import org.schabi.newpipe.util.ThemeHelper import org.schabi.newpipe.util.ThemeHelper
import org.schabi.newpipe.util.external_communication.ShareUtils
class AboutActivity : AppCompatActivity() { class AboutActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {

View file

@ -1,10 +1,7 @@
package org.schabi.newpipe.about package org.schabi.newpipe.about
import android.os.Bundle import android.os.Bundle
import android.view.ContextMenu
import android.view.ContextMenu.ContextMenuInfo
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.os.bundleOf 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.about.LicenseFragmentHelper.showLicense
import org.schabi.newpipe.databinding.FragmentLicensesBinding import org.schabi.newpipe.databinding.FragmentLicensesBinding
import org.schabi.newpipe.databinding.ItemSoftwareComponentBinding import org.schabi.newpipe.databinding.ItemSoftwareComponentBinding
import org.schabi.newpipe.util.ShareUtils
import java.util.Arrays import java.util.Arrays
import java.util.Objects import java.util.Objects
@ -23,7 +19,6 @@ import java.util.Objects
*/ */
class LicenseFragment : Fragment() { class LicenseFragment : Fragment() {
private lateinit var softwareComponents: Array<SoftwareComponent> private lateinit var softwareComponents: Array<SoftwareComponent>
private var componentForContextMenu: SoftwareComponent? = null
private var activeLicense: License? = null private var activeLicense: License? = null
private val compositeDisposable = CompositeDisposable() private val compositeDisposable = CompositeDisposable()
@ -73,7 +68,7 @@ class LicenseFragment : Fragment() {
root.setOnClickListener { root.setOnClickListener {
activeLicense = component.license activeLicense = component.license
compositeDisposable.add( compositeDisposable.add(
showLicense(activity, component.license) showLicense(activity, component)
) )
} }
binding.licensesSoftwareComponents.addView(root) binding.licensesSoftwareComponents.addView(root)
@ -87,30 +82,6 @@ class LicenseFragment : Fragment() {
return binding.root 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) { override fun onSaveInstanceState(savedInstanceState: Bundle) {
super.onSaveInstanceState(savedInstanceState) super.onSaveInstanceState(savedInstanceState)
if (activeLicense != null) { if (activeLicense != null) {

View file

@ -11,6 +11,7 @@ import io.reactivex.rxjava3.schedulers.Schedulers
import org.schabi.newpipe.R import org.schabi.newpipe.R
import org.schabi.newpipe.util.Localization import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.ThemeHelper import org.schabi.newpipe.util.ThemeHelper
import org.schabi.newpipe.util.external_communication.ShareUtils
import java.io.BufferedReader import java.io.BufferedReader
import java.io.IOException import java.io.IOException
import java.io.InputStreamReader 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()
}
}
}
} }

View file

@ -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 + "\"");
}
}

View file

@ -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 }
}
}

View file

@ -9,7 +9,8 @@ import androidx.room.Update
import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Flowable
import org.schabi.newpipe.database.feed.model.FeedEntity import org.schabi.newpipe.database.feed.model.FeedEntity
import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity 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 org.schabi.newpipe.database.subscription.SubscriptionEntity
import java.time.OffsetDateTime import java.time.OffsetDateTime
@ -20,21 +21,34 @@ abstract class FeedDAO {
@Query( @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 INNER JOIN feed f
ON s.uid = f.stream_id ON s.uid = f.stream_id
ORDER BY s.upload_date IS NULL DESC, s.upload_date DESC, s.uploader ASC ORDER BY s.upload_date IS NULL DESC, s.upload_date DESC, s.uploader ASC
LIMIT 500 LIMIT 500
""" """
) )
abstract fun getAllStreams(): Flowable<List<StreamEntity>> abstract fun getAllStreams(): Flowable<List<StreamWithState>>
@Query( @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 INNER JOIN feed f
ON s.uid = f.stream_id ON s.uid = f.stream_id
@ -42,16 +56,88 @@ abstract class FeedDAO {
INNER JOIN feed_group_subscription_join fgs INNER JOIN feed_group_subscription_join fgs
ON fgs.subscription_id = f.subscription_id ON fgs.subscription_id = f.subscription_id
INNER JOIN feed_group fg
ON fg.uid = fgs.group_id
WHERE fgs.group_id = :groupId WHERE fgs.group_id = :groupId
ORDER BY s.upload_date IS NULL DESC, s.upload_date DESC, s.uploader ASC ORDER BY s.upload_date IS NULL DESC, s.upload_date DESC, s.uploader ASC
LIMIT 500 LIMIT 500
""" """
) )
abstract fun getAllStreamsFromGroup(groupId: Long): Flowable<List<StreamEntity>> abstract fun getAllStreamsForGroup(groupId: Long): Flowable<List<StreamWithState>>
/**
* @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<List<StreamWithState>>
/**
* @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 getLiveOrNotPlayedStreamsForGroup(groupId: Long): Flowable<List<StreamWithState>>
@Query( @Query(
""" """

View file

@ -21,7 +21,7 @@ import static org.schabi.newpipe.database.stream.StreamStatisticsEntry.STREAM_WA
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_ID; 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.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.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; import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE;
@Dao @Dao
@ -80,7 +80,7 @@ public abstract class StreamHistoryDAO implements HistoryDAO<StreamHistoryEntity
+ " LEFT JOIN " + " LEFT JOIN "
+ "(SELECT " + JOIN_STREAM_ID + " AS " + JOIN_STREAM_ID_ALIAS + ", " + "(SELECT " + JOIN_STREAM_ID + " AS " + JOIN_STREAM_ID_ALIAS + ", "
+ STREAM_PROGRESS_TIME + STREAM_PROGRESS_MILLIS
+ " FROM " + STREAM_STATE_TABLE + " )" + " FROM " + STREAM_STATE_TABLE + " )"
+ " ON " + STREAM_ID + " = " + JOIN_STREAM_ID_ALIAS) + " ON " + STREAM_ID + " = " + JOIN_STREAM_ID_ALIAS)
public abstract Flowable<List<StreamStatisticsEntry>> getStatistics(); public abstract Flowable<List<StreamStatisticsEntry>> getStatistics();

View file

@ -12,8 +12,8 @@ data class PlaylistStreamEntry(
@Embedded @Embedded
val streamEntity: StreamEntity, val streamEntity: StreamEntity,
@ColumnInfo(name = StreamStateEntity.STREAM_PROGRESS_TIME, defaultValue = "0") @ColumnInfo(name = StreamStateEntity.STREAM_PROGRESS_MILLIS, defaultValue = "0")
val progressTime: Long, val progressMillis: Long,
@ColumnInfo(name = PlaylistStreamEntity.JOIN_STREAM_ID) @ColumnInfo(name = PlaylistStreamEntity.JOIN_STREAM_ID)
val streamId: Long, val streamId: Long,

View file

@ -14,26 +14,26 @@ import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_TABLE; import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_TABLE;
@Dao @Dao
public abstract class PlaylistDAO implements BasicDAO<PlaylistEntity> { public interface PlaylistDAO extends BasicDAO<PlaylistEntity> {
@Override @Override
@Query("SELECT * FROM " + PLAYLIST_TABLE) @Query("SELECT * FROM " + PLAYLIST_TABLE)
public abstract Flowable<List<PlaylistEntity>> getAll(); Flowable<List<PlaylistEntity>> getAll();
@Override @Override
@Query("DELETE FROM " + PLAYLIST_TABLE) @Query("DELETE FROM " + PLAYLIST_TABLE)
public abstract int deleteAll(); int deleteAll();
@Override @Override
public Flowable<List<PlaylistEntity>> listByService(final int serviceId) { default Flowable<List<PlaylistEntity>> listByService(final int serviceId) {
throw new UnsupportedOperationException(); throw new UnsupportedOperationException();
} }
@Query("SELECT * FROM " + PLAYLIST_TABLE + " WHERE " + PLAYLIST_ID + " = :playlistId") @Query("SELECT * FROM " + PLAYLIST_TABLE + " WHERE " + PLAYLIST_ID + " = :playlistId")
public abstract Flowable<List<PlaylistEntity>> getPlaylist(long playlistId); Flowable<List<PlaylistEntity>> getPlaylist(long playlistId);
@Query("DELETE FROM " + PLAYLIST_TABLE + " WHERE " + PLAYLIST_ID + " = :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) @Query("SELECT COUNT(*) FROM " + PLAYLIST_TABLE)
public abstract Flowable<Long> getCount(); Flowable<Long> getCount();
} }

View file

@ -17,31 +17,31 @@ import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.RE
import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_URL; import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_URL;
@Dao @Dao
public abstract class PlaylistRemoteDAO implements BasicDAO<PlaylistRemoteEntity> { public interface PlaylistRemoteDAO extends BasicDAO<PlaylistRemoteEntity> {
@Override @Override
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE) @Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE)
public abstract Flowable<List<PlaylistRemoteEntity>> getAll(); Flowable<List<PlaylistRemoteEntity>> getAll();
@Override @Override
@Query("DELETE FROM " + REMOTE_PLAYLIST_TABLE) @Query("DELETE FROM " + REMOTE_PLAYLIST_TABLE)
public abstract int deleteAll(); int deleteAll();
@Override @Override
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE @Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE
+ " WHERE " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId") + " WHERE " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId")
public abstract Flowable<List<PlaylistRemoteEntity>> listByService(int serviceId); Flowable<List<PlaylistRemoteEntity>> listByService(int serviceId);
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE " @Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE "
+ REMOTE_PLAYLIST_URL + " = :url AND " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId") + REMOTE_PLAYLIST_URL + " = :url AND " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId")
public abstract Flowable<List<PlaylistRemoteEntity>> getPlaylist(long serviceId, String url); Flowable<List<PlaylistRemoteEntity>> getPlaylist(long serviceId, String url);
@Query("SELECT " + REMOTE_PLAYLIST_ID + " FROM " + REMOTE_PLAYLIST_TABLE @Query("SELECT " + REMOTE_PLAYLIST_ID + " FROM " + REMOTE_PLAYLIST_TABLE
+ " WHERE " + REMOTE_PLAYLIST_URL + " = :url " + " WHERE " + REMOTE_PLAYLIST_URL + " = :url "
+ "AND " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId") + "AND " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId")
abstract Long getPlaylistIdInternal(long serviceId, String url); Long getPlaylistIdInternal(long serviceId, String url);
@Transaction @Transaction
public long upsert(final PlaylistRemoteEntity playlist) { default long upsert(final PlaylistRemoteEntity playlist) {
final Long playlistId = getPlaylistIdInternal(playlist.getServiceId(), playlist.getUrl()); final Long playlistId = getPlaylistIdInternal(playlist.getServiceId(), playlist.getUrl());
if (playlistId == null) { if (playlistId == null) {
@ -55,5 +55,5 @@ public abstract class PlaylistRemoteDAO implements BasicDAO<PlaylistRemoteEntity
@Query("DELETE FROM " + REMOTE_PLAYLIST_TABLE @Query("DELETE FROM " + REMOTE_PLAYLIST_TABLE
+ " WHERE " + REMOTE_PLAYLIST_ID + " = :playlistId") + " WHERE " + REMOTE_PLAYLIST_ID + " = :playlistId")
public abstract int deletePlaylist(long playlistId); int deletePlaylist(long playlistId);
} }

View file

@ -25,32 +25,32 @@ import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.PL
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_ID; 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.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.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; import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE;
@Dao @Dao
public abstract class PlaylistStreamDAO implements BasicDAO<PlaylistStreamEntity> { public interface PlaylistStreamDAO extends BasicDAO<PlaylistStreamEntity> {
@Override @Override
@Query("SELECT * FROM " + PLAYLIST_STREAM_JOIN_TABLE) @Query("SELECT * FROM " + PLAYLIST_STREAM_JOIN_TABLE)
public abstract Flowable<List<PlaylistStreamEntity>> getAll(); Flowable<List<PlaylistStreamEntity>> getAll();
@Override @Override
@Query("DELETE FROM " + PLAYLIST_STREAM_JOIN_TABLE) @Query("DELETE FROM " + PLAYLIST_STREAM_JOIN_TABLE)
public abstract int deleteAll(); int deleteAll();
@Override @Override
public Flowable<List<PlaylistStreamEntity>> listByService(final int serviceId) { default Flowable<List<PlaylistStreamEntity>> listByService(final int serviceId) {
throw new UnsupportedOperationException(); throw new UnsupportedOperationException();
} }
@Query("DELETE FROM " + PLAYLIST_STREAM_JOIN_TABLE @Query("DELETE FROM " + PLAYLIST_STREAM_JOIN_TABLE
+ " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId") + " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId")
public abstract void deleteBatch(long playlistId); void deleteBatch(long playlistId);
@Query("SELECT COALESCE(MAX(" + JOIN_INDEX + "), -1)" @Query("SELECT COALESCE(MAX(" + JOIN_INDEX + "), -1)"
+ " FROM " + PLAYLIST_STREAM_JOIN_TABLE + " FROM " + PLAYLIST_STREAM_JOIN_TABLE
+ " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId") + " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId")
public abstract Flowable<Integer> getMaximumIndexOf(long playlistId); Flowable<Integer> getMaximumIndexOf(long playlistId);
@Transaction @Transaction
@Query("SELECT * FROM " + STREAM_TABLE + " INNER JOIN " @Query("SELECT * FROM " + STREAM_TABLE + " INNER JOIN "
@ -64,12 +64,12 @@ public abstract class PlaylistStreamDAO implements BasicDAO<PlaylistStreamEntity
+ " LEFT JOIN " + " LEFT JOIN "
+ "(SELECT " + JOIN_STREAM_ID + " AS " + JOIN_STREAM_ID_ALIAS + ", " + "(SELECT " + JOIN_STREAM_ID + " AS " + JOIN_STREAM_ID_ALIAS + ", "
+ STREAM_PROGRESS_TIME + STREAM_PROGRESS_MILLIS
+ " FROM " + STREAM_STATE_TABLE + " )" + " FROM " + STREAM_STATE_TABLE + " )"
+ " ON " + STREAM_ID + " = " + JOIN_STREAM_ID_ALIAS + " ON " + STREAM_ID + " = " + JOIN_STREAM_ID_ALIAS
+ " ORDER BY " + JOIN_INDEX + " ASC") + " ORDER BY " + JOIN_INDEX + " ASC")
public abstract Flowable<List<PlaylistStreamEntry>> getOrderedStreamsOf(long playlistId); Flowable<List<PlaylistStreamEntry>> getOrderedStreamsOf(long playlistId);
@Transaction @Transaction
@Query("SELECT " + PLAYLIST_ID + ", " + PLAYLIST_NAME + ", " + PLAYLIST_THUMBNAIL_URL + ", " @Query("SELECT " + PLAYLIST_ID + ", " + PLAYLIST_NAME + ", " + PLAYLIST_THUMBNAIL_URL + ", "
@ -80,5 +80,5 @@ public abstract class PlaylistStreamDAO implements BasicDAO<PlaylistStreamEntity
+ " ON " + PLAYLIST_ID + " = " + JOIN_PLAYLIST_ID + " ON " + PLAYLIST_ID + " = " + JOIN_PLAYLIST_ID
+ " GROUP BY " + JOIN_PLAYLIST_ID + " GROUP BY " + JOIN_PLAYLIST_ID
+ " ORDER BY " + PLAYLIST_NAME + " COLLATE NOCASE ASC") + " ORDER BY " + PLAYLIST_NAME + " COLLATE NOCASE ASC")
public abstract Flowable<List<PlaylistMetadataEntry>> getPlaylistMetadata(); Flowable<List<PlaylistMetadataEntry>> getPlaylistMetadata();
} }

View file

@ -5,7 +5,7 @@ import androidx.room.Embedded
import org.schabi.newpipe.database.LocalItem import org.schabi.newpipe.database.LocalItem
import org.schabi.newpipe.database.history.model.StreamHistoryEntity import org.schabi.newpipe.database.history.model.StreamHistoryEntity
import org.schabi.newpipe.database.stream.model.StreamEntity 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 org.schabi.newpipe.extractor.stream.StreamInfoItem
import java.time.OffsetDateTime import java.time.OffsetDateTime
@ -13,8 +13,8 @@ class StreamStatisticsEntry(
@Embedded @Embedded
val streamEntity: StreamEntity, val streamEntity: StreamEntity,
@ColumnInfo(name = STREAM_PROGRESS_TIME, defaultValue = "0") @ColumnInfo(name = STREAM_PROGRESS_MILLIS, defaultValue = "0")
val progressTime: Long, val progressMillis: Long,
@ColumnInfo(name = StreamHistoryEntity.JOIN_STREAM_ID) @ColumnInfo(name = StreamHistoryEntity.JOIN_STREAM_ID)
val streamId: Long, val streamId: Long,

View file

@ -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?
)

View file

@ -17,31 +17,31 @@ import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_ST
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE; import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE;
@Dao @Dao
public abstract class StreamStateDAO implements BasicDAO<StreamStateEntity> { public interface StreamStateDAO extends BasicDAO<StreamStateEntity> {
@Override @Override
@Query("SELECT * FROM " + STREAM_STATE_TABLE) @Query("SELECT * FROM " + STREAM_STATE_TABLE)
public abstract Flowable<List<StreamStateEntity>> getAll(); Flowable<List<StreamStateEntity>> getAll();
@Override @Override
@Query("DELETE FROM " + STREAM_STATE_TABLE) @Query("DELETE FROM " + STREAM_STATE_TABLE)
public abstract int deleteAll(); int deleteAll();
@Override @Override
public Flowable<List<StreamStateEntity>> listByService(final int serviceId) { default Flowable<List<StreamStateEntity>> listByService(final int serviceId) {
throw new UnsupportedOperationException(); throw new UnsupportedOperationException();
} }
@Query("SELECT * FROM " + STREAM_STATE_TABLE + " WHERE " + JOIN_STREAM_ID + " = :streamId") @Query("SELECT * FROM " + STREAM_STATE_TABLE + " WHERE " + JOIN_STREAM_ID + " = :streamId")
public abstract Flowable<List<StreamStateEntity>> getState(long streamId); Flowable<List<StreamStateEntity>> getState(long streamId);
@Query("DELETE FROM " + STREAM_STATE_TABLE + " WHERE " + JOIN_STREAM_ID + " = :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) @Insert(onConflict = OnConflictStrategy.IGNORE)
abstract void silentInsertInternal(StreamStateEntity streamState); void silentInsertInternal(StreamStateEntity streamState);
@Transaction @Transaction
public long upsert(final StreamStateEntity stream) { default long upsert(final StreamStateEntity stream) {
silentInsertInternal(stream); silentInsertInternal(stream);
return update(stream); return update(stream);
} }

View file

@ -5,7 +5,7 @@ import androidx.room.ColumnInfo;
import androidx.room.Entity; import androidx.room.Entity;
import androidx.room.ForeignKey; import androidx.room.ForeignKey;
import java.util.concurrent.TimeUnit; import java.util.Objects;
import static androidx.room.ForeignKey.CASCADE; import static androidx.room.ForeignKey.CASCADE;
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_STREAM_ID; 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 // This additional field is required for the SQL query because 'stream_id' is used
// for some other joins already // for some other joins already
public static final String JOIN_STREAM_ID_ALIAS = "stream_id_alias"; 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) @ColumnInfo(name = JOIN_STREAM_ID)
private long streamUid; private long streamUid;
@ColumnInfo(name = STREAM_PROGRESS_TIME) @ColumnInfo(name = STREAM_PROGRESS_MILLIS)
private long progressTime; private long progressMillis;
public StreamStateEntity(final long streamUid, final long progressTime) { public StreamStateEntity(final long streamUid, final long progressMillis) {
this.streamUid = streamUid; this.streamUid = streamUid;
this.progressTime = progressTime; this.progressMillis = progressMillis;
} }
public long getStreamUid() { public long getStreamUid() {
@ -55,27 +60,53 @@ public class StreamStateEntity {
this.streamUid = streamUid; this.streamUid = streamUid;
} }
public long getProgressTime() { public long getProgressMillis() {
return progressTime; return progressMillis;
} }
public void setProgressTime(final long progressTime) { public void setProgressMillis(final long progressMillis) {
this.progressTime = progressTime; this.progressMillis = progressMillis;
} }
public boolean isValid(final int durationInSeconds) { /**
final int seconds = (int) TimeUnit.MILLISECONDS.toSeconds(progressTime); * The state will be considered valid, and thus be saved, if the progress is more than {@link
return seconds > PLAYBACK_SAVE_THRESHOLD_START_SECONDS * #PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS} or at least 1/4 of the video length.
&& seconds < durationInSeconds - PLAYBACK_SAVE_THRESHOLD_END_SECONDS; * @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;
}
/**
* 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 @Override
public boolean equals(@Nullable final Object obj) { public boolean equals(@Nullable final Object obj) {
if (obj instanceof StreamStateEntity) { if (obj instanceof StreamStateEntity) {
return ((StreamStateEntity) obj).streamUid == streamUid return ((StreamStateEntity) obj).streamUid == streamUid
&& ((StreamStateEntity) obj).progressTime == progressTime; && ((StreamStateEntity) obj).progressMillis == progressMillis;
} else { } else {
return false; return false;
} }
} }
@Override
public int hashCode() {
return Objects.hash(streamUid, progressMillis);
}
} }

View file

@ -3,6 +3,8 @@ package org.schabi.newpipe.download;
import android.app.Activity; import android.app.Activity;
import android.content.ComponentName; import android.content.ComponentName;
import android.content.Context; import android.content.Context;
import android.content.DialogInterface;
import android.content.DialogInterface.OnDismissListener;
import android.content.Intent; import android.content.Intent;
import android.content.ServiceConnection; import android.content.ServiceConnection;
import android.content.SharedPreferences; import android.content.SharedPreferences;
@ -20,6 +22,9 @@ import android.widget.RadioGroup;
import android.widget.SeekBar; import android.widget.SeekBar;
import android.widget.Toast; import android.widget.Toast;
import androidx.activity.result.ActivityResult;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult;
import androidx.annotation.IdRes; import androidx.annotation.IdRes;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
@ -35,7 +40,6 @@ import com.nononsenseapps.filepicker.Utils;
import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.MainActivity;
import org.schabi.newpipe.R; import org.schabi.newpipe.R;
import org.schabi.newpipe.RouterActivity;
import org.schabi.newpipe.databinding.DownloadDialogBinding; import org.schabi.newpipe.databinding.DownloadDialogBinding;
import org.schabi.newpipe.error.ErrorActivity; import org.schabi.newpipe.error.ErrorActivity;
import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.ErrorInfo;
@ -49,6 +53,8 @@ import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.extractor.stream.SubtitlesStream; import org.schabi.newpipe.extractor.stream.SubtitlesStream;
import org.schabi.newpipe.extractor.stream.VideoStream; import org.schabi.newpipe.extractor.stream.VideoStream;
import org.schabi.newpipe.settings.NewPipeSettings; 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.FilePickerActivityHelper;
import org.schabi.newpipe.util.FilenameUtils; import org.schabi.newpipe.util.FilenameUtils;
import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.ListHelper;
@ -69,8 +75,6 @@ import icepick.Icepick;
import icepick.State; import icepick.State;
import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.CompositeDisposable;
import us.shandian.giga.get.MissionRecoveryInfo; 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.postprocessing.Postprocessing;
import us.shandian.giga.service.DownloadManager; import us.shandian.giga.service.DownloadManager;
import us.shandian.giga.service.DownloadManagerService; import us.shandian.giga.service.DownloadManagerService;
@ -83,7 +87,6 @@ public class DownloadDialog extends DialogFragment
implements RadioGroup.OnCheckedChangeListener, AdapterView.OnItemSelectedListener { implements RadioGroup.OnCheckedChangeListener, AdapterView.OnItemSelectedListener {
private static final String TAG = "DialogFragment"; private static final String TAG = "DialogFragment";
private static final boolean DEBUG = MainActivity.DEBUG; private static final boolean DEBUG = MainActivity.DEBUG;
private static final int REQUEST_DOWNLOAD_SAVE_AS = 0x1230;
@State @State
StreamInfo currentInfo; StreamInfo currentInfo;
@ -100,6 +103,9 @@ public class DownloadDialog extends DialogFragment
@State @State
int selectedSubtitleIndex = 0; int selectedSubtitleIndex = 0;
@Nullable
private OnDismissListener onDismissListener = null;
private StoredDirectoryHelper mainStorageAudio = null; private StoredDirectoryHelper mainStorageAudio = null;
private StoredDirectoryHelper mainStorageVideo = null; private StoredDirectoryHelper mainStorageVideo = null;
private DownloadManager downloadManager = null; private DownloadManager downloadManager = null;
@ -119,6 +125,25 @@ public class DownloadDialog extends DialogFragment
private VideoSegment[] segments; private VideoSegment[] segments;
// Variables for file name and MIME type when picking new folder because it's not set yet
private String filenameTmp;
private String mimeTmp;
private final ActivityResultLauncher<Intent> requestDownloadSaveAsLauncher =
registerForActivityResult(
new StartActivityForResult(), this::requestDownloadSaveAsResult);
private final ActivityResultLauncher<Intent> requestDownloadPickAudioFolderLauncher =
registerForActivityResult(
new StartActivityForResult(), this::requestDownloadPickAudioFolderResult);
private final ActivityResultLauncher<Intent> requestDownloadPickVideoFolderLauncher =
registerForActivityResult(
new StartActivityForResult(), this::requestDownloadPickVideoFolderResult);
/*//////////////////////////////////////////////////////////////////////////
// Instance creation
//////////////////////////////////////////////////////////////////////////*/
public static DownloadDialog newInstance(final StreamInfo info) { public static DownloadDialog newInstance(final StreamInfo info) {
final DownloadDialog dialog = new DownloadDialog(); final DownloadDialog dialog = new DownloadDialog();
dialog.setInfo(info); dialog.setInfo(info);
@ -140,6 +165,11 @@ public class DownloadDialog extends DialogFragment
return instance; return instance;
} }
/*//////////////////////////////////////////////////////////////////////////
// Setters
//////////////////////////////////////////////////////////////////////////*/
private void setInfo(final StreamInfo info) { private void setInfo(final StreamInfo info) {
this.currentInfo = info; this.currentInfo = info;
} }
@ -156,10 +186,6 @@ public class DownloadDialog extends DialogFragment
setVideoStreams(new StreamSizeWrapper<>(videoStreams, getContext())); setVideoStreams(new StreamSizeWrapper<>(videoStreams, getContext()));
} }
/*//////////////////////////////////////////////////////////////////////////
// LifeCycle
//////////////////////////////////////////////////////////////////////////*/
public void setVideoStreams(final StreamSizeWrapper<VideoStream> wvs) { public void setVideoStreams(final StreamSizeWrapper<VideoStream> wvs) {
this.wrappedVideoStreams = wvs; this.wrappedVideoStreams = wvs;
} }
@ -189,6 +215,14 @@ public class DownloadDialog extends DialogFragment
this.segments = seg; this.segments = seg;
} }
public void setOnDismissListener(@Nullable final OnDismissListener onDismissListener) {
this.onDismissListener = onDismissListener;
}
/*//////////////////////////////////////////////////////////////////////////
// Android lifecycle
//////////////////////////////////////////////////////////////////////////*/
@Override @Override
public void onCreate(@Nullable final Bundle savedInstanceState) { public void onCreate(@Nullable final Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
@ -199,7 +233,7 @@ public class DownloadDialog extends DialogFragment
if (!PermissionHelper.checkStoragePermissions(getActivity(), if (!PermissionHelper.checkStoragePermissions(getActivity(),
PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE)) { PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE)) {
getDialog().dismiss(); dismiss();
return; return;
} }
@ -258,10 +292,6 @@ public class DownloadDialog extends DialogFragment
}, Context.BIND_AUTO_CREATE); }, Context.BIND_AUTO_CREATE);
} }
/*//////////////////////////////////////////////////////////////////////////
// Inits
//////////////////////////////////////////////////////////////////////////*/
@Override @Override
public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container,
final Bundle savedInstanceState) { final Bundle savedInstanceState) {
@ -317,6 +347,60 @@ public class DownloadDialog extends DialogFragment
fetchStreamsSize(); fetchStreamsSize();
} }
private void initToolbar(final Toolbar toolbar) {
if (DEBUG) {
Log.d(TAG, "initToolbar() called with: toolbar = [" + toolbar + "]");
}
toolbar.setTitle(R.string.download_dialog_title);
toolbar.setNavigationIcon(R.drawable.ic_arrow_back);
toolbar.inflateMenu(R.menu.dialog_url);
toolbar.setNavigationOnClickListener(v -> dismiss());
toolbar.setNavigationContentDescription(R.string.cancel);
okButton = toolbar.findViewById(R.id.okay);
okButton.setEnabled(false); // disable until the download service connection is done
toolbar.setOnMenuItemClickListener(item -> {
if (item.getItemId() == R.id.okay) {
prepareSelectedDownload();
return true;
}
return false;
});
}
@Override
public void onDismiss(@NonNull final DialogInterface dialog) {
super.onDismiss(dialog);
if (onDismissListener != null) {
onDismissListener.onDismiss(dialog);
}
}
@Override
public void onDestroy() {
super.onDestroy();
disposables.clear();
}
@Override
public void onDestroyView() {
dialogBinding = null;
super.onDestroyView();
}
@Override
public void onSaveInstanceState(@NonNull final Bundle outState) {
super.onSaveInstanceState(outState);
Icepick.saveInstanceState(this, outState);
}
/*//////////////////////////////////////////////////////////////////////////
// Video, audio and subtitle spinners
//////////////////////////////////////////////////////////////////////////*/
private void fetchStreamsSize() { private void fetchStreamsSize() {
disposables.clear(); disposables.clear();
disposables.add(StreamSizeWrapper.fetchSizeForWrapper(wrappedVideoStreams) disposables.add(StreamSizeWrapper.fetchSizeForWrapper(wrappedVideoStreams)
@ -351,91 +435,6 @@ public class DownloadDialog extends DialogFragment
currentInfo.getServiceId())))); currentInfo.getServiceId()))));
} }
@Override
public void onDestroy() {
super.onDestroy();
disposables.clear();
}
@Override
public void onDestroyView() {
dialogBinding = null;
super.onDestroyView();
}
/*//////////////////////////////////////////////////////////////////////////
// Radio group Video&Audio options - Listener
//////////////////////////////////////////////////////////////////////////*/
@Override
public void onSaveInstanceState(@NonNull final Bundle outState) {
super.onSaveInstanceState(outState);
Icepick.saveInstanceState(this, outState);
}
/*//////////////////////////////////////////////////////////////////////////
// Streams Spinner Listener
//////////////////////////////////////////////////////////////////////////*/
@Override
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 (FilePickerActivityHelper.isOwnFileUri(context, data.getData())) {
final File file = Utils.getFileForUri(data.getData());
checkSelectedDownload(null, Uri.fromFile(file), file.getName(),
StoredFileHelper.DEFAULT_MIME);
return;
}
final DocumentFile docFile = DocumentFile.fromSingleUri(context, data.getData());
if (docFile == null) {
showFailedDialog(R.string.general_error);
return;
}
// check if the selected file was previously used
checkSelectedDownload(null, data.getData(), docFile.getName(),
docFile.getType());
}
}
private void initToolbar(final Toolbar toolbar) {
if (DEBUG) {
Log.d(TAG, "initToolbar() called with: toolbar = [" + toolbar + "]");
}
toolbar.setTitle(R.string.download_dialog_title);
toolbar.setNavigationIcon(R.drawable.ic_arrow_back);
toolbar.inflateMenu(R.menu.dialog_url);
toolbar.setNavigationOnClickListener(v -> requireDialog().dismiss());
toolbar.setNavigationContentDescription(R.string.cancel);
okButton = toolbar.findViewById(R.id.okay);
okButton.setEnabled(false); // disable until the download service connection is done
toolbar.setOnMenuItemClickListener(item -> {
if (item.getItemId() == R.id.okay) {
prepareSelectedDownload();
if (getActivity() instanceof RouterActivity) {
getActivity().finish();
}
return true;
}
return false;
});
}
/*//////////////////////////////////////////////////////////////////////////
// Utils
//////////////////////////////////////////////////////////////////////////*/
private void setupAudioSpinner() { private void setupAudioSpinner() {
if (getContext() == null) { if (getContext() == null) {
return; return;
@ -466,6 +465,88 @@ public class DownloadDialog extends DialogFragment
setRadioButtonsState(true); setRadioButtonsState(true);
} }
/*//////////////////////////////////////////////////////////////////////////
// Activity results
//////////////////////////////////////////////////////////////////////////*/
private void requestDownloadPickAudioFolderResult(final ActivityResult result) {
requestDownloadPickFolderResult(
result, getString(R.string.download_path_audio_key), DownloadManager.TAG_AUDIO);
}
private void requestDownloadPickVideoFolderResult(final ActivityResult result) {
requestDownloadPickFolderResult(
result, getString(R.string.download_path_video_key), DownloadManager.TAG_VIDEO);
}
private void requestDownloadSaveAsResult(final ActivityResult result) {
if (result.getResultCode() != Activity.RESULT_OK) {
return;
}
if (result.getData() == null || result.getData().getData() == null) {
showFailedDialog(R.string.general_error);
return;
}
if (FilePickerActivityHelper.isOwnFileUri(context, result.getData().getData())) {
final File file = Utils.getFileForUri(result.getData().getData());
checkSelectedDownload(null, Uri.fromFile(file), file.getName(),
StoredFileHelper.DEFAULT_MIME);
return;
}
final DocumentFile docFile
= DocumentFile.fromSingleUri(context, result.getData().getData());
if (docFile == null) {
showFailedDialog(R.string.general_error);
return;
}
// check if the selected file was previously used
checkSelectedDownload(null, result.getData().getData(), docFile.getName(),
docFile.getType());
}
private void requestDownloadPickFolderResult(final ActivityResult result,
final String key,
final String tag) {
if (result.getResultCode() != Activity.RESULT_OK) {
return;
}
if (result.getData() == null || result.getData().getData() == null) {
showFailedDialog(R.string.general_error);
return;
}
Uri uri = result.getData().getData();
if (FilePickerActivityHelper.isOwnFileUri(context, uri)) {
uri = Uri.fromFile(Utils.getFileForUri(uri));
} else {
context.grantUriPermission(context.getPackageName(), uri,
StoredDirectoryHelper.PERMISSION_FLAGS);
}
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);
}
}
/*//////////////////////////////////////////////////////////////////////////
// Listeners
//////////////////////////////////////////////////////////////////////////*/
@Override @Override
public void onCheckedChanged(final RadioGroup group, @IdRes final int checkedId) { public void onCheckedChanged(final RadioGroup group, @IdRes final int checkedId) {
if (DEBUG) { if (DEBUG) {
@ -515,6 +596,11 @@ public class DownloadDialog extends DialogFragment
public void onNothingSelected(final AdapterView<?> parent) { public void onNothingSelected(final AdapterView<?> parent) {
} }
/*//////////////////////////////////////////////////////////////////////////
// Download
//////////////////////////////////////////////////////////////////////////*/
protected void setupDownloadOptions() { protected void setupDownloadOptions() {
setRadioButtonsState(false); setRadioButtonsState(false);
@ -527,7 +613,7 @@ public class DownloadDialog extends DialogFragment
dialogBinding.subtitleButton.setVisibility(isSubtitleStreamsAvailable dialogBinding.subtitleButton.setVisibility(isSubtitleStreamsAvailable
? View.VISIBLE : View.GONE); ? View.VISIBLE : View.GONE);
prefs = PreferenceManager.getDefaultSharedPreferences(getContext()); prefs = PreferenceManager.getDefaultSharedPreferences(requireContext());
final String defaultMedia = prefs.getString(getString(R.string.last_used_download_type), final String defaultMedia = prefs.getString(getString(R.string.last_used_download_type),
getString(R.string.last_download_type_video_key)); getString(R.string.last_download_type_video_key));
@ -555,7 +641,7 @@ public class DownloadDialog extends DialogFragment
} else { } else {
Toast.makeText(getContext(), R.string.no_streams_available_download, Toast.makeText(getContext(), R.string.no_streams_available_download,
Toast.LENGTH_SHORT).show(); Toast.LENGTH_SHORT).show();
getDialog().dismiss(); dismiss();
} }
} }
@ -607,87 +693,97 @@ public class DownloadDialog extends DialogFragment
.show(); .show();
} }
private void launchDirectoryPicker(final ActivityResultLauncher<Intent> launcher) {
launcher.launch(StoredDirectoryHelper.getPicker(context));
}
private void prepareSelectedDownload() { private void prepareSelectedDownload() {
final StoredDirectoryHelper mainStorage; final StoredDirectoryHelper mainStorage;
final MediaFormat format; final MediaFormat format;
final String mime;
final String selectedMediaType; final String selectedMediaType;
// first, build the filename and get the output folder (if possible) // first, build the filename and get the output folder (if possible)
// later, run a very very very large file checking logic // later, run a very very very large file checking logic
String filename = getNameEditText().concat("."); filenameTmp = getNameEditText().concat(".");
switch (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()) { switch (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()) {
case R.id.audio_button: case R.id.audio_button:
selectedMediaType = getString(R.string.last_download_type_audio_key); selectedMediaType = getString(R.string.last_download_type_audio_key);
mainStorage = mainStorageAudio; mainStorage = mainStorageAudio;
format = audioStreamsAdapter.getItem(selectedAudioIndex).getFormat(); format = audioStreamsAdapter.getItem(selectedAudioIndex).getFormat();
switch (format) { if (format == MediaFormat.WEBMA_OPUS) {
case WEBMA_OPUS: mimeTmp = "audio/ogg";
mime = "audio/ogg"; filenameTmp += "opus";
filename += "opus"; } else {
break; mimeTmp = format.mimeType;
default: filenameTmp += format.suffix;
mime = format.mimeType;
filename += format.suffix;
break;
} }
break; break;
case R.id.video_button: case R.id.video_button:
selectedMediaType = getString(R.string.last_download_type_video_key); selectedMediaType = getString(R.string.last_download_type_video_key);
mainStorage = mainStorageVideo; mainStorage = mainStorageVideo;
format = videoStreamsAdapter.getItem(selectedVideoIndex).getFormat(); format = videoStreamsAdapter.getItem(selectedVideoIndex).getFormat();
mime = format.mimeType; mimeTmp = format.mimeType;
filename += format.suffix; filenameTmp += format.suffix;
break; break;
case R.id.subtitle_button: case R.id.subtitle_button:
selectedMediaType = getString(R.string.last_download_type_subtitle_key); selectedMediaType = getString(R.string.last_download_type_subtitle_key);
mainStorage = mainStorageVideo; // subtitle & video files go together mainStorage = mainStorageVideo; // subtitle & video files go together
format = subtitleStreamsAdapter.getItem(selectedSubtitleIndex).getFormat(); format = subtitleStreamsAdapter.getItem(selectedSubtitleIndex).getFormat();
mime = format.mimeType; mimeTmp = format.mimeType;
filename += (format == MediaFormat.TTML ? MediaFormat.SRT : format).suffix; filenameTmp += (format == MediaFormat.TTML ? MediaFormat.SRT : format).suffix;
break; break;
default: default:
throw new RuntimeException("No stream selected"); throw new RuntimeException("No stream selected");
} }
if (mainStorage == null || askForSavePath) { if (!askForSavePath
// This part is called if with SAF preferred: && (mainStorage == null
// * older android version running || mainStorage.isDirect() == NewPipeSettings.useStorageAccessFramework(context)
// * save path not defined (via download settings) || mainStorage.isInvalidSafStorage())) {
// * the user checked the "ask where to download" option // Pick new download folder if one of:
// - Download folder is not set
if (!askForSavePath) { // - Download folder uses SAF while SAF is disabled
Toast.makeText(context, getString(R.string.no_available_dir), // - 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(); Toast.LENGTH_LONG).show();
}
if (NewPipeSettings.useStorageAccessFramework(context)) {
StoredFileHelper.requestSafWithFileCreation(this, REQUEST_DOWNLOAD_SAVE_AS,
filename, mime);
} else {
File initialSavePath;
if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() == R.id.audio_button) { if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() == R.id.audio_button) {
initialSavePath = NewPipeSettings.getDir(Environment.DIRECTORY_MUSIC); launchDirectoryPicker(requestDownloadPickAudioFolderLauncher);
} else { } else {
initialSavePath = NewPipeSettings.getDir(Environment.DIRECTORY_MOVIES); launchDirectoryPicker(requestDownloadPickVideoFolderLauncher);
}
initialSavePath = new File(initialSavePath, filename);
startActivityForResult(FilePickerActivityHelper.chooseFileToSave(context,
initialSavePath.getAbsolutePath()), REQUEST_DOWNLOAD_SAVE_AS);
} }
return; return;
} }
if (askForSavePath) {
final Uri initialPath;
if (NewPipeSettings.useStorageAccessFramework(context)) {
initialPath = null;
} else {
final File initialSavePath;
if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() == R.id.audio_button) {
initialSavePath = NewPipeSettings.getDir(Environment.DIRECTORY_MUSIC);
} else {
initialSavePath = NewPipeSettings.getDir(Environment.DIRECTORY_MOVIES);
}
initialPath = Uri.parse(initialSavePath.getAbsolutePath());
}
requestDownloadSaveAsLauncher.launch(StoredFileHelper.getNewPicker(context,
filenameTmp, mimeTmp, initialPath));
return;
}
// check for existing file with the same name // 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 // remember the last media type downloaded by the user
prefs.edit() prefs.edit().putString(getString(R.string.last_used_download_type), selectedMediaType)
.putString(getString(R.string.last_used_download_type), selectedMediaType)
.apply(); .apply();
} }
@ -715,15 +811,14 @@ public class DownloadDialog extends DialogFragment
return; return;
} }
// check if is our file // get state of potential mission referring to the same file
final MissionState state = downloadManager.checkForExistingMission(storage); final MissionState state = downloadManager.checkForExistingMission(storage);
@StringRes @StringRes final int msgBtn;
final int msgBtn; @StringRes final int msgBody;
@StringRes
final int msgBody;
// this switch checks if there is already a mission referring to the same file
switch (state) { switch (state) {
case Finished: case Finished: // there is already a finished mission
msgBtn = R.string.overwrite; msgBtn = R.string.overwrite;
msgBody = R.string.overwrite_finished_warning; msgBody = R.string.overwrite_finished_warning;
break; break;
@ -735,7 +830,7 @@ public class DownloadDialog extends DialogFragment
msgBtn = R.string.generate_unique_name; msgBtn = R.string.generate_unique_name;
msgBody = R.string.download_already_running; msgBody = R.string.download_already_running;
break; break;
case None: case None: // there is no mission referring to the same file
if (mainStorage == null) { if (mainStorage == null) {
// This part is called if: // This part is called if:
// * using SAF on older android version // * using SAF on older android version
@ -770,7 +865,7 @@ public class DownloadDialog extends DialogFragment
msgBody = R.string.overwrite_unrelated_warning; msgBody = R.string.overwrite_unrelated_warning;
break; break;
default: default:
return; return; // unreachable
} }
final AlertDialog.Builder askDialog = new AlertDialog.Builder(context) final AlertDialog.Builder askDialog = new AlertDialog.Builder(context)

View file

@ -29,7 +29,7 @@ import org.schabi.newpipe.MainActivity;
import org.schabi.newpipe.R; import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.ActivityErrorBinding; import org.schabi.newpipe.databinding.ActivityErrorBinding;
import org.schabi.newpipe.util.Localization; 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 org.schabi.newpipe.util.ThemeHelper;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@ -204,7 +204,8 @@ public class ErrorActivity extends AppCompatActivity {
onBackPressed(); onBackPressed();
return true; return true;
case R.id.menu_item_share_error: 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; return true;
default: default:
return false; return false;
@ -229,13 +230,10 @@ public class ErrorActivity extends AppCompatActivity {
+ getString(R.string.app_name) + " " + getString(R.string.app_name) + " "
+ BuildConfig.VERSION_NAME) + BuildConfig.VERSION_NAME)
.putExtra(Intent.EXTRA_TEXT, buildJson()); .putExtra(Intent.EXTRA_TEXT, buildJson());
if (i.resolveActivity(getPackageManager()) != null) { ShareUtils.openIntentInApp(context, i, true);
ShareUtils.openIntentInApp(context, i);
}
} else if (action.equals("GITHUB")) { // open the NewPipe issue page on GitHub } else if (action.equals("GITHUB")) { // open the NewPipe issue page on GitHub
ShareUtils.openUrlInBrowser(this, ERROR_GITHUB_ISSUE_URL, false); ShareUtils.openUrlInBrowser(this, ERROR_GITHUB_ISSUE_URL, false);
} }
}) })
.setNegativeButton(R.string.decline, (dialog, which) -> { .setNegativeButton(R.string.decline, (dialog, which) -> {
// do nothing // do nothing

View file

@ -6,6 +6,7 @@ import kotlinx.android.parcel.Parcelize
import org.schabi.newpipe.R import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.Info import org.schabi.newpipe.extractor.Info
import org.schabi.newpipe.extractor.NewPipe 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.ContentNotAvailableException
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException
import org.schabi.newpipe.extractor.exceptions.ExtractionException import org.schabi.newpipe.extractor.exceptions.ExtractionException
@ -95,6 +96,7 @@ class ErrorInfo(
action: UserAction action: UserAction
): Int { ): Int {
return when { return when {
throwable is AccountTerminatedException -> R.string.account_terminated
throwable is ContentNotAvailableException -> R.string.content_not_available throwable is ContentNotAvailableException -> R.string.content_not_available
throwable != null && throwable.isNetworkRelated -> R.string.network_error throwable != null && throwable.isNetworkRelated -> R.string.network_error
throwable is ContentNotSupportedException -> R.string.content_not_supported throwable is ContentNotSupportedException -> R.string.content_not_supported

View file

@ -13,6 +13,8 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.disposables.Disposable
import org.schabi.newpipe.MainActivity import org.schabi.newpipe.MainActivity
import org.schabi.newpipe.R 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.AgeRestrictedContentException
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException 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.ReCaptchaException
import org.schabi.newpipe.extractor.exceptions.SoundCloudGoPlusContentException import org.schabi.newpipe.extractor.exceptions.SoundCloudGoPlusContentException
import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentException 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.animate
import org.schabi.newpipe.ktx.isInterruptedCaused import org.schabi.newpipe.ktx.isInterruptedCaused
import org.schabi.newpipe.ktx.isNetworkRelated import org.schabi.newpipe.ktx.isNetworkRelated
import org.schabi.newpipe.util.ServiceHelper
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class ErrorPanelHelper( class ErrorPanelHelper(
@ -35,6 +39,8 @@ class ErrorPanelHelper(
private val context: Context = rootView.context!! private val context: Context = rootView.context!!
private val errorPanelRoot: View = rootView.findViewById(R.id.error_panel) private val errorPanelRoot: View = rootView.findViewById(R.id.error_panel)
private val errorTextView: TextView = errorPanelRoot.findViewById(R.id.error_message_view) 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 errorButtonAction: Button = errorPanelRoot.findViewById(R.id.error_button_action)
private val errorButtonRetry: Button = errorPanelRoot.findViewById(R.id.error_button_retry) private val errorButtonRetry: Button = errorPanelRoot.findViewById(R.id.error_button_retry)
@ -70,13 +76,40 @@ class ErrorPanelHelper(
errorButtonAction.setOnClickListener(null) errorButtonAction.setOnClickListener(null)
} }
errorTextView.setText(R.string.recaptcha_request_toast) errorTextView.setText(R.string.recaptcha_request_toast)
// additional info is only provided by AccountTerminatedException
errorServiceInfoTextView.isVisible = false
errorServiceExplenationTextView.isVisible = false
errorButtonRetry.isVisible = true 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 { } else {
errorButtonAction.setText(R.string.error_snackbar_action) errorButtonAction.setText(R.string.error_snackbar_action)
errorButtonAction.setOnClickListener { errorButtonAction.setOnClickListener {
ErrorActivity.reportError(context, errorInfo) 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 // hide retry button by default, then show only if not unavailable/unsupported content
errorButtonRetry.isVisible = false errorButtonRetry.isVisible = false
errorTextView.setText( errorTextView.setText(

View file

@ -130,7 +130,7 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte
Log.d(TAG, "onCreateOptionsMenu() called with: " Log.d(TAG, "onCreateOptionsMenu() called with: "
+ "menu = [" + menu + "], inflater = [" + inflater + "]"); + "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(); final ActionBar supportActionBar = activity.getSupportActionBar();
if (supportActionBar != null) { if (supportActionBar != null) {

View file

@ -23,15 +23,15 @@ import org.schabi.newpipe.extractor.stream.Description;
import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper; 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.TextLinkifier; import org.schabi.newpipe.util.external_communication.TextLinkifier;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import icepick.State; import icepick.State;
import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.disposables.CompositeDisposable;
import static android.text.TextUtils.isEmpty; import static android.text.TextUtils.isEmpty;
import static org.schabi.newpipe.extractor.stream.StreamExtractor.NO_AGE_LIMIT; import static org.schabi.newpipe.extractor.stream.StreamExtractor.NO_AGE_LIMIT;
@ -41,8 +41,7 @@ public class DescriptionFragment extends BaseFragment {
@State @State
StreamInfo streamInfo = null; StreamInfo streamInfo = null;
@Nullable final CompositeDisposable descriptionDisposables = new CompositeDisposable();
Disposable descriptionDisposable = null;
FragmentDescriptionBinding binding; FragmentDescriptionBinding binding;
public DescriptionFragment() { public DescriptionFragment() {
@ -67,10 +66,8 @@ public class DescriptionFragment extends BaseFragment {
@Override @Override
public void onDestroy() { public void onDestroy() {
descriptionDisposables.clear();
super.onDestroy(); super.onDestroy();
if (descriptionDisposable != null) {
descriptionDisposable.dispose();
}
} }
@ -133,17 +130,17 @@ public class DescriptionFragment extends BaseFragment {
final Description description = streamInfo.getDescription(); final Description description = streamInfo.getDescription();
switch (description.getType()) { switch (description.getType()) {
case Description.HTML: case Description.HTML:
descriptionDisposable = TextLinkifier.createLinksFromHtmlBlock(requireContext(), TextLinkifier.createLinksFromHtmlBlock(binding.detailDescriptionView,
description.getContent(), binding.detailDescriptionView, description.getContent(), HtmlCompat.FROM_HTML_MODE_LEGACY, streamInfo,
HtmlCompat.FROM_HTML_MODE_LEGACY); descriptionDisposables);
break; break;
case Description.MARKDOWN: case Description.MARKDOWN:
descriptionDisposable = TextLinkifier.createLinksFromMarkdownText(requireContext(), TextLinkifier.createLinksFromMarkdownText(binding.detailDescriptionView,
description.getContent(), binding.detailDescriptionView); description.getContent(), streamInfo, descriptionDisposables);
break; break;
case Description.PLAIN_TEXT: default: case Description.PLAIN_TEXT: default:
descriptionDisposable = TextLinkifier.createLinksFromPlainText(requireContext(), TextLinkifier.createLinksFromPlainText(binding.detailDescriptionView,
description.getContent(), binding.detailDescriptionView); description.getContent(), streamInfo, descriptionDisposables);
break; break;
} }
} }
@ -198,8 +195,8 @@ public class DescriptionFragment extends BaseFragment {
}); });
if (linkifyContent) { if (linkifyContent) {
TextLinkifier.createLinksFromPlainText(requireContext(), TextLinkifier.createLinksFromPlainText(itemBinding.metadataContentView, content, null,
content, itemBinding.metadataContentView); descriptionDisposables);
} else { } else {
itemBinding.metadataContentView.setText(content); itemBinding.metadataContentView.setText(content);
} }

View file

@ -91,12 +91,12 @@ import org.schabi.newpipe.util.Constants;
import org.schabi.newpipe.util.DeviceUtils; import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.ExtractorHelper;
import org.schabi.newpipe.util.ImageDisplayConstants; 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.ListHelper;
import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.PermissionHelper; 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.SponsorBlockUtils; import org.schabi.newpipe.util.SponsorBlockUtils;
import org.schabi.newpipe.util.ThemeHelper; import org.schabi.newpipe.util.ThemeHelper;
@ -461,8 +461,8 @@ public final class VideoDetailFragment
break; break;
case R.id.detail_controls_share: case R.id.detail_controls_share:
if (currentInfo != null) { if (currentInfo != null) {
ShareUtils.shareText(requireContext(), ShareUtils.shareText(requireContext(), currentInfo.getName(),
currentInfo.getName(), currentInfo.getUrl()); currentInfo.getUrl(), currentInfo.getThumbnailUrl());
} }
break; break;
case R.id.detail_controls_open_in_browser: case R.id.detail_controls_open_in_browser:
@ -479,7 +479,7 @@ public final class VideoDetailFragment
if (DEBUG) { if (DEBUG) {
Log.i(TAG, "Failed to start kore", e); Log.i(TAG, "Failed to start kore", e);
} }
KoreUtil.showInstallKoreDialog(requireContext()); KoreUtils.showInstallKoreDialog(requireContext());
} }
} }
break; break;
@ -638,7 +638,7 @@ public final class VideoDetailFragment
binding.detailControlsShare.setOnClickListener(this); binding.detailControlsShare.setOnClickListener(this);
binding.detailControlsOpenInBrowser.setOnClickListener(this); binding.detailControlsOpenInBrowser.setOnClickListener(this);
binding.detailControlsPlayWithKodi.setOnClickListener(this); binding.detailControlsPlayWithKodi.setOnClickListener(this);
binding.detailControlsPlayWithKodi.setVisibility(KoreUtil.shouldShowPlayWithKodi( binding.detailControlsPlayWithKodi.setVisibility(KoreUtils.shouldShowPlayWithKodi(
requireContext(), serviceId) ? View.VISIBLE : View.GONE); requireContext(), serviceId) ? View.VISIBLE : View.GONE);
binding.overlayThumbnail.setOnClickListener(this); binding.overlayThumbnail.setOnClickListener(this);
@ -1553,8 +1553,8 @@ public final class VideoDetailFragment
.getDefaultResolutionIndex(activity, sortedVideoStreams); .getDefaultResolutionIndex(activity, sortedVideoStreams);
updateProgressInfo(info); updateProgressInfo(info);
initThumbnailViews(info); initThumbnailViews(info);
disposables.add(showMetaInfoInTextView(info.getMetaInfo(), binding.detailMetaInfoTextView, showMetaInfoInTextView(info.getMetaInfo(), binding.detailMetaInfoTextView,
binding.detailMetaInfoSeparator)); binding.detailMetaInfoSeparator, disposables);
if (player == null || player.isStopped()) { if (player == null || player.isStopped()) {
updateOverlayData(info.getName(), info.getUploaderName(), info.getThumbnailUrl()); updateOverlayData(info.getName(), info.getUploaderName(), info.getThumbnailUrl());
@ -1689,7 +1689,7 @@ public final class VideoDetailFragment
.onErrorComplete() .onErrorComplete()
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe(state -> { .subscribe(state -> {
showPlaybackProgress(state.getProgressTime(), info.getDuration() * 1000); showPlaybackProgress(state.getProgressMillis(), info.getDuration() * 1000);
animate(binding.positionView, true, 500); animate(binding.positionView, true, 500);
animate(binding.detailPositionView, true, 500); animate(binding.detailPositionView, true, 500);
}, e -> { }, e -> {

View file

@ -33,7 +33,7 @@ import org.schabi.newpipe.fragments.OnScrollBelowItemsListener;
import org.schabi.newpipe.info_list.InfoItemDialog; import org.schabi.newpipe.info_list.InfoItemDialog;
import org.schabi.newpipe.info_list.InfoListAdapter; import org.schabi.newpipe.info_list.InfoListAdapter;
import org.schabi.newpipe.player.helper.PlayerHolder; 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.NavigationHelper;
import org.schabi.newpipe.util.OnClickGesture; import org.schabi.newpipe.util.OnClickGesture;
import org.schabi.newpipe.util.StateSaver; import org.schabi.newpipe.util.StateSaver;
@ -371,7 +371,7 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
)); ));
} }
entries.add(StreamDialogEntry.open_in_browser); entries.add(StreamDialogEntry.open_in_browser);
if (KoreUtil.shouldShowPlayWithKodi(context, item.getServiceId())) { if (KoreUtils.shouldShowPlayWithKodi(context, item.getServiceId())) {
entries.add(StreamDialogEntry.play_with_kodi); entries.add(StreamDialogEntry.play_with_kodi);
} }
if (!isNullOrEmpty(item.getUploaderUrl())) { if (!isNullOrEmpty(item.getUploaderUrl())) {
@ -389,7 +389,8 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
@Override @Override
public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { public void onCreateOptionsMenu(@NonNull final Menu menu,
@NonNull final MenuInflater inflater) {
if (DEBUG) { if (DEBUG) {
Log.d(TAG, "onCreateOptionsMenu() called with: " Log.d(TAG, "onCreateOptionsMenu() called with: "
+ "menu = [" + menu + "], inflater = [" + inflater + "]"); + "menu = [" + menu + "], inflater = [" + inflater + "]");

View file

@ -43,7 +43,7 @@ import org.schabi.newpipe.util.ExtractorHelper;
import org.schabi.newpipe.util.ImageDisplayConstants; import org.schabi.newpipe.util.ImageDisplayConstants;
import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper; 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 org.schabi.newpipe.util.ThemeHelper;
import java.util.ArrayList; import java.util.ArrayList;
@ -164,7 +164,8 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
@Override @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); super.onCreateOptionsMenu(menu, inflater);
final ActionBar supportActionBar = activity.getSupportActionBar(); final ActionBar supportActionBar = activity.getSupportActionBar();
if (useAsFrontPage && supportActionBar != null) { if (useAsFrontPage && supportActionBar != null) {
@ -203,7 +204,8 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
break; break;
case R.id.menu_item_share: case R.id.menu_item_share:
if (currentInfo != null) { if (currentInfo != null) {
ShareUtils.shareText(requireContext(), name, currentInfo.getOriginalUrl()); ShareUtils.shareText(requireContext(), name, currentInfo.getOriginalUrl(),
currentInfo.getAvatarUrl());
} }
break; break;
default: default:

View file

@ -85,7 +85,8 @@ public class CommentsFragment extends BaseListInfoFragment<CommentsInfo> {
public void setTitle(final String title) { } public void setTitle(final String title) { }
@Override @Override
public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { } public void onCreateOptionsMenu(@NonNull final Menu menu,
@NonNull final MenuInflater inflater) { }
@Override @Override
protected boolean isGridLayout() { protected boolean isGridLayout() {

View file

@ -131,7 +131,8 @@ public class KioskFragment extends BaseListInfoFragment<KioskInfo> {
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
@Override @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); super.onCreateOptionsMenu(menu, inflater);
final ActionBar supportActionBar = activity.getSupportActionBar(); final ActionBar supportActionBar = activity.getSupportActionBar();
if (supportActionBar != null && useAsFrontPage) { if (supportActionBar != null && useAsFrontPage) {

View file

@ -42,10 +42,10 @@ import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue; import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue;
import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.ExtractorHelper;
import org.schabi.newpipe.util.ImageDisplayConstants; 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.Localization;
import org.schabi.newpipe.util.NavigationHelper; 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 org.schabi.newpipe.util.StreamDialogEntry;
import java.util.ArrayList; import java.util.ArrayList;
@ -162,7 +162,7 @@ public class PlaylistFragment extends BaseListInfoFragment<PlaylistInfo> {
)); ));
} }
entries.add(StreamDialogEntry.open_in_browser); entries.add(StreamDialogEntry.open_in_browser);
if (KoreUtil.shouldShowPlayWithKodi(context, item.getServiceId())) { if (KoreUtils.shouldShowPlayWithKodi(context, item.getServiceId())) {
entries.add(StreamDialogEntry.play_with_kodi); entries.add(StreamDialogEntry.play_with_kodi);
} }
@ -181,7 +181,8 @@ public class PlaylistFragment extends BaseListInfoFragment<PlaylistInfo> {
} }
@Override @Override
public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { public void onCreateOptionsMenu(@NonNull final Menu menu,
@NonNull final MenuInflater inflater) {
if (DEBUG) { if (DEBUG) {
Log.d(TAG, "onCreateOptionsMenu() called with: " Log.d(TAG, "onCreateOptionsMenu() called with: "
+ "menu = [" + menu + "], inflater = [" + inflater + "]"); + "menu = [" + menu + "], inflater = [" + inflater + "]");
@ -251,7 +252,7 @@ public class PlaylistFragment extends BaseListInfoFragment<PlaylistInfo> {
ShareUtils.openUrlInBrowser(requireContext(), url); ShareUtils.openUrlInBrowser(requireContext(), url);
break; break;
case R.id.menu_item_share: case R.id.menu_item_share:
ShareUtils.shareText(requireContext(), name, url); ShareUtils.shareText(requireContext(), name, url, currentInfo.getThumbnailUrl());
break; break;
case R.id.menu_item_bookmark: case R.id.menu_item_bookmark:
onBookmarkClicked(); onBookmarkClicked();

View file

@ -227,6 +227,25 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
initSearchListeners(); 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 @Override
public void onPause() { public void onPause() {
super.onPause(); super.onPause();
@ -250,13 +269,6 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
} }
super.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()) { if (suggestionDisposable == null || suggestionDisposable.isDisposed()) {
initSuggestionObserver(); initSuggestionObserver();
} }
@ -278,8 +290,9 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
handleSearchSuggestion(); handleSearchSuggestion();
disposables.add(showMetaInfoInTextView(metaInfo == null ? null : Arrays.asList(metaInfo), showMetaInfoInTextView(metaInfo == null ? null : Arrays.asList(metaInfo),
searchBinding.searchMetaInfoTextView, searchBinding.searchMetaInfoSeparator)); searchBinding.searchMetaInfoTextView, searchBinding.searchMetaInfoSeparator,
disposables);
if (TextUtils.isEmpty(searchString) || wasSearchFocused) { if (TextUtils.isEmpty(searchString) || wasSearchFocused) {
showKeyboardSearch(); showKeyboardSearch();
@ -412,7 +425,8 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
@Override @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); super.onCreateOptionsMenu(menu, inflater);
final ActionBar supportActionBar = activity.getSupportActionBar(); final ActionBar supportActionBar = activity.getSupportActionBar();
@ -426,6 +440,12 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
int itemId = 0; int itemId = 0;
boolean isFirstItem = true; boolean isFirstItem = true;
final Context c = getContext(); final Context c = getContext();
if (service == null) {
Log.w(TAG, "onCreateOptionsMenu() called with null service");
updateService();
}
for (final String filter : service.getSearchQHFactory().getAvailableContentFilter()) { for (final String filter : service.getSearchQHFactory().getAvailableContentFilter()) {
if (filter.equals(YoutubeSearchQueryHandlerFactory.MUSIC_SONGS)) { if (filter.equals(YoutubeSearchQueryHandlerFactory.MUSIC_SONGS)) {
final MenuItem musicItem = menu.add(2, final MenuItem musicItem = menu.add(2,
@ -841,7 +861,7 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
infoListAdapter.clearStreamItemList(); infoListAdapter.clearStreamItemList();
hideSuggestionsPanel(); hideSuggestionsPanel();
showMetaInfoInTextView(null, searchBinding.searchMetaInfoTextView, showMetaInfoInTextView(null, searchBinding.searchMetaInfoTextView,
searchBinding.searchMetaInfoSeparator); searchBinding.searchMetaInfoSeparator, disposables);
hideKeyboardSearch(); hideKeyboardSearch();
disposables.add(historyRecordManager.onSearched(serviceId, theSearchString) disposables.add(historyRecordManager.onSearched(serviceId, theSearchString)
@ -986,8 +1006,8 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
// List<MetaInfo> cannot be bundled without creating some containers // List<MetaInfo> cannot be bundled without creating some containers
metaInfo = new MetaInfo[result.getMetaInfo().size()]; metaInfo = new MetaInfo[result.getMetaInfo().size()];
metaInfo = result.getMetaInfo().toArray(metaInfo); metaInfo = result.getMetaInfo().toArray(metaInfo);
disposables.add(showMetaInfoInTextView(result.getMetaInfo(), showMetaInfoInTextView(result.getMetaInfo(), searchBinding.searchMetaInfoTextView,
searchBinding.searchMetaInfoTextView, searchBinding.searchMetaInfoSeparator)); searchBinding.searchMetaInfoSeparator, disposables);
handleSearchSuggestion(); handleSearchSuggestion();

View file

@ -140,7 +140,8 @@ public class RelatedItemsFragment extends BaseListInfoFragment<RelatedItemInfo>
} }
@Override @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) { private void setInitialData(final StreamInfo info) {

View file

@ -24,7 +24,7 @@ import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.ImageDisplayConstants; import org.schabi.newpipe.util.ImageDisplayConstants;
import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper; 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.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;

View file

@ -66,7 +66,7 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder {
itemProgressView.setVisibility(View.VISIBLE); itemProgressView.setVisibility(View.VISIBLE);
itemProgressView.setMax((int) item.getDuration()); itemProgressView.setMax((int) item.getDuration());
itemProgressView.setProgress((int) TimeUnit.MILLISECONDS itemProgressView.setProgress((int) TimeUnit.MILLISECONDS
.toSeconds(state2.getProgressTime())); .toSeconds(state2.getProgressMillis()));
} else { } else {
itemProgressView.setVisibility(View.GONE); itemProgressView.setVisibility(View.GONE);
} }
@ -121,10 +121,10 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder {
itemProgressView.setMax((int) item.getDuration()); itemProgressView.setMax((int) item.getDuration());
if (itemProgressView.getVisibility() == View.VISIBLE) { if (itemProgressView.getVisibility() == View.VISIBLE) {
itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS
.toSeconds(state.getProgressTime())); .toSeconds(state.getProgressMillis()));
} else { } else {
itemProgressView.setProgress((int) TimeUnit.MILLISECONDS itemProgressView.setProgress((int) TimeUnit.MILLISECONDS
.toSeconds(state.getProgressTime())); .toSeconds(state.getProgressMillis()));
ViewUtils.animate(itemProgressView, true, 500); ViewUtils.animate(itemProgressView, true, 500);
} }
} else if (itemProgressView.getVisibility() == View.VISIBLE) { } else if (itemProgressView.getVisibility() == View.VISIBLE) {

View file

@ -1,7 +1,6 @@
package org.schabi.newpipe.local; package org.schabi.newpipe.local;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import android.content.res.Configuration;
import android.content.res.Resources; import android.content.res.Resources;
import android.os.Bundle; import android.os.Bundle;
import android.util.Log; import android.util.Log;
@ -9,6 +8,7 @@ import android.view.Menu;
import android.view.MenuInflater; import android.view.MenuInflater;
import android.view.View; import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.ActionBar;
import androidx.fragment.app.Fragment; import androidx.fragment.app.Fragment;
@ -25,6 +25,7 @@ import org.schabi.newpipe.fragments.list.ListViewContract;
import static org.schabi.newpipe.ktx.ViewUtils.animate; import static org.schabi.newpipe.ktx.ViewUtils.animate;
import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling; import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling;
import static org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout;
/** /**
* This fragment is design to be used with persistent data such as * This fragment is design to be used with persistent data such as
@ -76,7 +77,7 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
super.onResume(); super.onResume();
if (updateFlags != 0) { if (updateFlags != 0) {
if ((updateFlags & LIST_MODE_UPDATE_FLAG) != 0) { if ((updateFlags & LIST_MODE_UPDATE_FLAG) != 0) {
final boolean useGrid = isGridLayout(); final boolean useGrid = shouldUseGridLayout(requireContext());
itemsList.setLayoutManager( itemsList.setLayoutManager(
useGrid ? getGridLayoutManager() : getListLayoutManager()); useGrid ? getGridLayoutManager() : getListLayoutManager());
itemListAdapter.setUseGridVariant(useGrid); itemListAdapter.setUseGridVariant(useGrid);
@ -120,7 +121,7 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
itemListAdapter = new LocalItemListAdapter(activity); itemListAdapter = new LocalItemListAdapter(activity);
final boolean useGrid = isGridLayout(); final boolean useGrid = shouldUseGridLayout(requireContext());
itemsList = rootView.findViewById(R.id.items_list); itemsList = rootView.findViewById(R.id.items_list);
itemsList.setLayoutManager(useGrid ? getGridLayoutManager() : getListLayoutManager()); itemsList.setLayoutManager(useGrid ? getGridLayoutManager() : getListLayoutManager());
@ -145,7 +146,8 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
@Override @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); super.onCreateOptionsMenu(menu, inflater);
if (DEBUG) { if (DEBUG) {
Log.d(TAG, "onCreateOptionsMenu() called with: " Log.d(TAG, "onCreateOptionsMenu() called with: "
@ -258,17 +260,4 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
updateFlags |= LIST_MODE_UPDATE_FLAG; updateFlags |= LIST_MODE_UPDATE_FLAG;
} }
} }
protected boolean isGridLayout() {
final String listMode = PreferenceManager.getDefaultSharedPreferences(activity)
.getString(getString(R.string.list_view_mode_key),
getString(R.string.list_view_mode_value));
if ("auto".equals(listMode)) {
final Configuration configuration = getResources().getConfiguration();
return configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
&& configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE);
} else {
return "grid".equals(listMode);
}
}
} }

View file

@ -12,6 +12,7 @@ import org.schabi.newpipe.NewPipeDatabase
import org.schabi.newpipe.database.feed.model.FeedEntity import org.schabi.newpipe.database.feed.model.FeedEntity
import org.schabi.newpipe.database.feed.model.FeedGroupEntity import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity 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.database.stream.model.StreamEntity
import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.extractor.stream.StreamType import org.schabi.newpipe.extractor.stream.StreamType
@ -38,16 +39,19 @@ class FeedDatabaseManager(context: Context) {
fun database() = database fun database() = database
fun asStreamItems(groupId: Long = FeedGroupEntity.GROUP_ALL_ID): Flowable<List<StreamInfoItem>> { fun getStreams(
val streams = when (groupId) { groupId: Long = FeedGroupEntity.GROUP_ALL_ID,
FeedGroupEntity.GROUP_ALL_ID -> feedTable.getAllStreams() getPlayedStreams: Boolean = true
else -> feedTable.getAllStreamsFromGroup(groupId) ): Flowable<List<StreamWithState>> {
return when (groupId) {
FeedGroupEntity.GROUP_ALL_ID -> {
if (getPlayedStreams) feedTable.getAllStreams()
else feedTable.getLiveOrNotPlayedStreams()
}
else -> {
if (getPlayedStreams) feedTable.getAllStreamsForGroup(groupId)
else feedTable.getLiveOrNotPlayedStreamsForGroup(groupId)
} }
return streams.map {
val items = ArrayList<StreamInfoItem>(it.size)
it.mapTo(items) { stream -> stream.toStreamInfoItem() }
return@map items
} }
} }
@ -60,8 +64,10 @@ class FeedDatabaseManager(context: Context) {
} }
} }
fun outdatedSubscriptionsForGroup(groupId: Long = FeedGroupEntity.GROUP_ALL_ID, outdatedThreshold: OffsetDateTime) = fun outdatedSubscriptionsForGroup(
feedTable.getAllOutdatedForGroup(groupId, outdatedThreshold) groupId: Long = FeedGroupEntity.GROUP_ALL_ID,
outdatedThreshold: OffsetDateTime
) = feedTable.getAllOutdatedForGroup(groupId, outdatedThreshold)
fun markAsOutdated(subscriptionId: Long) = feedTable fun markAsOutdated(subscriptionId: Long) = feedTable
.setLastUpdatedForSubscription(FeedLastUpdatedEntity(subscriptionId, null)) .setLastUpdatedForSubscription(FeedLastUpdatedEntity(subscriptionId, null))
@ -93,10 +99,7 @@ class FeedDatabaseManager(context: Context) {
} }
feedTable.setLastUpdatedForSubscription( feedTable.setLastUpdatedForSubscription(
FeedLastUpdatedEntity( FeedLastUpdatedEntity(subscriptionId, OffsetDateTime.now(ZoneOffset.UTC))
subscriptionId,
OffsetDateTime.now(ZoneOffset.UTC)
)
) )
} }
@ -108,7 +111,12 @@ class FeedDatabaseManager(context: Context) {
fun clear() { fun clear() {
feedTable.deleteAll() feedTable.deleteAll()
val deletedOrphans = streamTable.deleteOrphans() 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<Long>): Completable { fun updateSubscriptionsForGroup(groupId: Long, subscriptionIds: List<Long>): Completable {
return Completable.fromCallable { feedGroupTable.updateSubscriptionsForGroup(groupId, subscriptionIds) } return Completable
.fromCallable { feedGroupTable.updateSubscriptionsForGroup(groupId, subscriptionIds) }
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
} }

View file

@ -19,7 +19,10 @@
package org.schabi.newpipe.local.feed package org.schabi.newpipe.local.feed
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences
import android.os.Bundle import android.os.Bundle
import android.os.Parcelable import android.os.Parcelable
import android.view.LayoutInflater import android.view.LayoutInflater
@ -28,41 +31,74 @@ import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.annotation.Nullable
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.edit import androidx.core.content.edit
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.preference.PreferenceManager 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 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.R
import org.schabi.newpipe.database.feed.model.FeedGroupEntity 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.databinding.FragmentFeedBinding
import org.schabi.newpipe.error.ErrorInfo import org.schabi.newpipe.error.ErrorInfo
import org.schabi.newpipe.error.UserAction 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.animate
import org.schabi.newpipe.ktx.animateHideRecyclerViewAllowingScrolling 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.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.Localization
import org.schabi.newpipe.util.NavigationHelper
import org.schabi.newpipe.util.StreamDialogEntry
import org.schabi.newpipe.util.ThemeHelper.getGridSpanCount
import org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.util.ArrayList
class FeedFragment : BaseListFragment<FeedState, Unit>() { class FeedFragment : BaseStateFragment<FeedState>() {
private var _feedBinding: FragmentFeedBinding? = null private var _feedBinding: FragmentFeedBinding? = null
private val feedBinding get() = _feedBinding!! private val feedBinding get() = _feedBinding!!
private val disposables = CompositeDisposable()
private lateinit var viewModel: FeedViewModel private lateinit var viewModel: FeedViewModel
@State @State @JvmField var listState: Parcelable? = null
@JvmField
var listState: Parcelable? = null
private var groupId = FeedGroupEntity.GROUP_ALL_ID private var groupId = FeedGroupEntity.GROUP_ALL_ID
private var groupName = "" private var groupName = ""
private var oldestSubscriptionUpdate: OffsetDateTime? = null private var oldestSubscriptionUpdate: OffsetDateTime? = null
private lateinit var groupAdapter: GroupAdapter<GroupieViewHolder>
@State @JvmField var showPlayedItems: Boolean = true
private var onSettingsChangeListener: SharedPreferences.OnSharedPreferenceChangeListener? = null
private var updateListViewModeOnResume = false
init { init {
setHasOptionsMenu(true) setHasOptionsMenu(true)
setUseDefaultStateSaving(false)
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -71,6 +107,14 @@ class FeedFragment : BaseListFragment<FeedState, Unit>() {
groupId = arguments?.getLong(KEY_GROUP_ID, FeedGroupEntity.GROUP_ALL_ID) groupId = arguments?.getLong(KEY_GROUP_ID, FeedGroupEntity.GROUP_ALL_ID)
?: FeedGroupEntity.GROUP_ALL_ID ?: FeedGroupEntity.GROUP_ALL_ID
groupName = arguments?.getString(KEY_GROUP_NAME) ?: "" 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? { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
@ -82,8 +126,17 @@ class FeedFragment : BaseListFragment<FeedState, Unit>() {
_feedBinding = FragmentFeedBinding.bind(rootView) _feedBinding = FragmentFeedBinding.bind(rootView)
super.onViewCreated(rootView, savedInstanceState) super.onViewCreated(rootView, savedInstanceState)
viewModel = ViewModelProvider(this, FeedViewModel.Factory(requireContext(), groupId)).get(FeedViewModel::class.java) val factory = FeedViewModel.Factory(requireContext(), groupId, showPlayedItems)
viewModel.stateLiveData.observe(viewLifecycleOwner) { it?.let(::handleResult) } viewModel = ViewModelProvider(this, factory).get(FeedViewModel::class.java)
viewModel.stateLiveData.observe(viewLifecycleOwner, { it?.let(::handleResult) })
groupAdapter = GroupAdapter<GroupieViewHolder>().apply {
setOnItemClickListener(listenerStreamItem)
setOnItemLongClickListener(listenerStreamItem)
}
feedBinding.itemsList.adapter = groupAdapter
setupListViewMode()
} }
override fun onPause() { override fun onPause() {
@ -94,6 +147,23 @@ class FeedFragment : BaseListFragment<FeedState, Unit>() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
updateRelativeTimeViews() 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(context)) getGridSpanCount(context) else 1
feedBinding.itemsList.layoutManager = GridLayoutManager(requireContext(), groupAdapter.spanCount).apply {
spanSizeLookup = groupAdapter.spanSizeLookup
}
} }
override fun setUserVisibleHint(isVisibleToUser: Boolean) { override fun setUserVisibleHint(isVisibleToUser: Boolean) {
@ -116,21 +186,21 @@ class FeedFragment : BaseListFragment<FeedState, Unit>() {
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
super.onCreateOptionsMenu(menu, inflater) super.onCreateOptionsMenu(menu, inflater)
activity.supportActionBar?.setDisplayShowTitleEnabled(true)
activity.supportActionBar?.setTitle(R.string.fragment_feed_title) activity.supportActionBar?.setTitle(R.string.fragment_feed_title)
activity.supportActionBar?.subtitle = groupName activity.supportActionBar?.subtitle = groupName
inflater.inflate(R.menu.menu_feed_fragment, menu) inflater.inflate(R.menu.menu_feed_fragment, menu)
updateTogglePlayedItemsButton(menu.findItem(R.id.menu_item_feed_toggle_played_items))
if (useAsFrontPage) {
menu.findItem(R.id.menu_item_feed_help).setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER)
}
} }
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == R.id.menu_item_feed_help) { if (item.itemId == R.id.menu_item_feed_help) {
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) 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 { val enableDisableButtonText = when {
usingDedicatedMethod -> R.string.feed_use_dedicated_fetch_method_disable_button usingDedicatedMethod -> R.string.feed_use_dedicated_fetch_method_disable_button
else -> R.string.feed_use_dedicated_fetch_method_enable_button else -> R.string.feed_use_dedicated_fetch_method_enable_button
@ -147,6 +217,10 @@ class FeedFragment : BaseListFragment<FeedState, Unit>() {
.create() .create()
.show() .show()
return true 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) return super.onOptionsItemSelected(item)
@ -158,18 +232,34 @@ class FeedFragment : BaseListFragment<FeedState, Unit>() {
} }
override fun onDestroy() { override fun onDestroy() {
disposables.dispose()
if (onSettingsChangeListener != null) {
PreferenceManager.getDefaultSharedPreferences(activity)
.unregisterOnSharedPreferenceChangeListener(onSettingsChangeListener)
onSettingsChangeListener = null
}
super.onDestroy() super.onDestroy()
activity?.supportActionBar?.subtitle = null activity?.supportActionBar?.subtitle = null
} }
override fun onDestroyView() { override fun onDestroyView() {
feedBinding.itemsList.adapter = null
_feedBinding = null _feedBinding = null
super.onDestroyView() 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 // Handling
// ///////////////////////////////////////////////////////////////////////// // //////////////////////////////////////////////////////////////////////////
override fun showLoading() { override fun showLoading() {
super.showLoading() super.showLoading()
@ -181,6 +271,7 @@ class FeedFragment : BaseListFragment<FeedState, Unit>() {
override fun hideLoading() { override fun hideLoading() {
super.hideLoading() super.hideLoading()
feedBinding.itemsList.animate(true, 0)
feedBinding.refreshRootView.animate(true, 200) feedBinding.refreshRootView.animate(true, 200)
feedBinding.loadingProgressText.animate(false, 0) feedBinding.loadingProgressText.animate(false, 0)
feedBinding.swipeRefreshLayout.isRefreshing = false feedBinding.swipeRefreshLayout.isRefreshing = false
@ -206,7 +297,6 @@ class FeedFragment : BaseListFragment<FeedState, Unit>() {
override fun handleError() { override fun handleError() {
super.handleError() super.handleError()
infoListAdapter.clearStreamItemList()
feedBinding.itemsList.animateHideRecyclerViewAllowingScrolling() feedBinding.itemsList.animateHideRecyclerViewAllowingScrolling()
feedBinding.refreshRootView.animate(false, 0) feedBinding.refreshRootView.animate(false, 0)
feedBinding.loadingProgressText.animate(false, 0) feedBinding.loadingProgressText.animate(false, 0)
@ -234,24 +324,96 @@ class FeedFragment : BaseListFragment<FeedState, Unit>() {
feedBinding.loadingProgressBar.max = progressState.maxProgress 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<StreamDialogEntry>()
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) { private fun handleLoadedState(loadedState: FeedState.LoadedState) {
infoListAdapter.setInfoItemList(loadedState.items)
val itemVersion = if (shouldUseGridLayout(context)) {
StreamItem.ItemVersion.GRID
} else {
StreamItem.ItemVersion.NORMAL
}
loadedState.items.forEach { it.itemVersion = itemVersion }
groupAdapter.updateAsync(loadedState.items, false, null)
listState?.run { listState?.run {
feedBinding.itemsList.layoutManager?.onRestoreInstanceState(listState) feedBinding.itemsList.layoutManager?.onRestoreInstanceState(listState)
listState = null listState = null
} }
oldestSubscriptionUpdate = loadedState.oldestUpdate val feedsNotLoaded = loadedState.notLoadedCount > 0
feedBinding.refreshSubtitleText.isVisible = feedsNotLoaded
val loadedCount = loadedState.notLoadedCount > 0 if (feedsNotLoaded) {
feedBinding.refreshSubtitleText.isVisible = loadedCount
if (loadedCount) {
feedBinding.refreshSubtitleText.text = getString( feedBinding.refreshSubtitleText.text = getString(
R.string.feed_subscription_not_loaded_count, R.string.feed_subscription_not_loaded_count,
loadedState.notLoadedCount 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()) { if (loadedState.items.isEmpty()) {
showEmptyState() showEmptyState()
} else { } else {
@ -269,9 +431,78 @@ class FeedFragment : BaseListFragment<FeedState, Unit>() {
} }
} }
private fun handleItemsErrors(errors: List<Throwable>) {
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<Throwable>
) {
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() { private fun updateRelativeTimeViews() {
updateRefreshViewState() updateRefreshViewState()
infoListAdapter.notifyDataSetChanged() groupAdapter.notifyItemRangeChanged(
0, groupAdapter.itemCount,
StreamItem.UPDATE_RELATIVE_TIME
)
} }
private fun updateRefreshViewState() { private fun updateRefreshViewState() {
@ -286,8 +517,6 @@ class FeedFragment : BaseListFragment<FeedState, Unit>() {
// ///////////////////////////////////////////////////////////////////////// // /////////////////////////////////////////////////////////////////////////
override fun doInitialLoadLogic() {} override fun doInitialLoadLogic() {}
override fun loadMoreItems() {}
override fun hasMoreItems() = false
override fun reloadContent() { override fun reloadContent() {
getActivity()?.startService( getActivity()?.startService(

View file

@ -1,7 +1,7 @@
package org.schabi.newpipe.local.feed package org.schabi.newpipe.local.feed
import androidx.annotation.StringRes import androidx.annotation.StringRes
import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.local.feed.item.StreamItem
import java.time.OffsetDateTime import java.time.OffsetDateTime
sealed class FeedState { sealed class FeedState {
@ -12,7 +12,7 @@ sealed class FeedState {
) : FeedState() ) : FeedState()
data class LoadedState( data class LoadedState(
val items: List<StreamInfoItem>, val items: List<StreamItem>,
val oldestUpdate: OffsetDateTime? = null, val oldestUpdate: OffsetDateTime? = null,
val notLoadedCount: Long, val notLoadedCount: Long,
val itemsErrors: List<Throwable> = emptyList() val itemsErrors: List<Throwable> = emptyList()

View file

@ -8,9 +8,11 @@ import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.functions.Function4 import io.reactivex.rxjava3.functions.Function4
import io.reactivex.rxjava3.processors.BehaviorProcessor
import io.reactivex.rxjava3.schedulers.Schedulers import io.reactivex.rxjava3.schedulers.Schedulers
import org.schabi.newpipe.database.feed.model.FeedGroupEntity 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
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ErrorResultEvent import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ErrorResultEvent
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.IdleEvent 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.time.OffsetDateTime
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class FeedViewModel(applicationContext: Context, val groupId: Long = FeedGroupEntity.GROUP_ALL_ID) : ViewModel() { class FeedViewModel(
class Factory(val context: Context, val groupId: Long = FeedGroupEntity.GROUP_ALL_ID) : ViewModelProvider.Factory { applicationContext: Context,
@Suppress("UNCHECKED_CAST") groupId: Long = FeedGroupEntity.GROUP_ALL_ID,
override fun <T : ViewModel?> create(modelClass: Class<T>): T { initialShowPlayedItems: Boolean = true
return FeedViewModel(context.applicationContext, groupId) as T ) : ViewModel() {
}
}
private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(applicationContext) private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(applicationContext)
private val toggleShowPlayedItems = BehaviorProcessor.create<Boolean>()
private val streamItems = toggleShowPlayedItems
.startWithItem(initialShowPlayedItems)
.distinctUntilChanged()
.switchMap { showPlayedItems ->
feedDatabaseManager.getStreams(groupId, showPlayedItems)
}
private val mutableStateLiveData = MutableLiveData<FeedState>() private val mutableStateLiveData = MutableLiveData<FeedState>()
val stateLiveData: LiveData<FeedState> = mutableStateLiveData val stateLiveData: LiveData<FeedState> = mutableStateLiveData
private var combineDisposable = Flowable private var combineDisposable = Flowable
.combineLatest( .combineLatest(
FeedEventManager.events(), FeedEventManager.events(),
feedDatabaseManager.asStreamItems(groupId), streamItems,
feedDatabaseManager.notLoadedCount(groupId), feedDatabaseManager.notLoadedCount(groupId),
feedDatabaseManager.oldestSubscriptionUpdate(groupId), feedDatabaseManager.oldestSubscriptionUpdate(groupId),
Function4 { t1: FeedEventManager.Event, t2: List<StreamInfoItem>, t3: Long, t4: List<OffsetDateTime> ->
Function4 { t1: FeedEventManager.Event, t2: List<StreamWithState>,
t3: Long, t4: List<OffsetDateTime> ->
return@Function4 CombineResultHolder(t1, t2, t3, t4.firstOrNull()) 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) -> .subscribe { (event, listFromDB, notLoadedCount, oldestUpdate) ->
mutableStateLiveData.postValue( mutableStateLiveData.postValue(
when (event) { 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 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) is ErrorResultEvent -> FeedState.ErrorState(event.error)
} }
) )
@ -66,5 +75,20 @@ class FeedViewModel(applicationContext: Context, val groupId: Long = FeedGroupEn
combineDisposable.dispose() combineDisposable.dispose()
} }
private data class CombineResultHolder(val t1: FeedEventManager.Event, val t2: List<StreamInfoItem>, val t3: Long, val t4: OffsetDateTime?) private data class CombineResultHolder(val t1: FeedEventManager.Event, val t2: List<StreamWithState>, 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 <T : ViewModel?> create(modelClass: Class<T>): T {
return FeedViewModel(context.applicationContext, groupId, showPlayedItems) as T
}
}
} }

View file

@ -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<ListStreamItemBinding>() {
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<Any>) {
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
}
}

View file

@ -48,9 +48,7 @@ import org.schabi.newpipe.MainActivity.DEBUG
import org.schabi.newpipe.R import org.schabi.newpipe.R
import org.schabi.newpipe.database.feed.model.FeedGroupEntity import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.extractor.ListInfo import org.schabi.newpipe.extractor.ListInfo
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
import org.schabi.newpipe.extractor.stream.StreamInfoItem 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.FeedDatabaseManager
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ErrorResultEvent import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ErrorResultEvent
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ProgressEvent 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.feed.service.FeedEventManager.postEvent
import org.schabi.newpipe.local.subscription.SubscriptionManager import org.schabi.newpipe.local.subscription.SubscriptionManager
import org.schabi.newpipe.util.ExtractorHelper import org.schabi.newpipe.util.ExtractorHelper
import java.io.IOException
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.time.ZoneOffset import java.time.ZoneOffset
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@ -162,7 +159,7 @@ class FeedLoadService : Service() {
// Loading & Handling // 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 { companion object {
fun wrapList(subscriptionId: Long, info: ListInfo<StreamInfoItem>): List<Throwable> { fun wrapList(subscriptionId: Long, info: ListInfo<StreamInfoItem>): List<Throwable> {
val toReturn = ArrayList<Throwable>(info.errors.size) val toReturn = ArrayList<Throwable>(info.errors.size)
@ -209,29 +206,40 @@ class FeedLoadService : Service() {
.filter { !cancelSignal.get() } .filter { !cancelSignal.get() }
.map { subscriptionEntity -> .map { subscriptionEntity ->
var error: Throwable? = null
try { try {
val listInfo = if (useFeedExtractor) { val listInfo = if (useFeedExtractor) {
ExtractorHelper ExtractorHelper
.getFeedInfoFallbackToChannelInfo(subscriptionEntity.serviceId, subscriptionEntity.url) .getFeedInfoFallbackToChannelInfo(subscriptionEntity.serviceId, subscriptionEntity.url)
.onErrorReturn {
error = it // store error, otherwise wrapped into RuntimeException
throw it
}
.blockingGet() .blockingGet()
} else { } else {
ExtractorHelper ExtractorHelper
.getChannelInfo(subscriptionEntity.serviceId, subscriptionEntity.url, true) .getChannelInfo(subscriptionEntity.serviceId, subscriptionEntity.url, true)
.onErrorReturn {
error = it // store error, otherwise wrapped into RuntimeException
throw it
}
.blockingGet() .blockingGet()
} as ListInfo<StreamInfoItem> } as ListInfo<StreamInfoItem>
return@map Notification.createOnNext(Pair(subscriptionEntity.uid, listInfo)) return@map Notification.createOnNext(Pair(subscriptionEntity.uid, listInfo))
} catch (e: Throwable) { } catch (e: Throwable) {
if (error == null) {
// do this to prevent blockingGet() from wrapping into RuntimeException
error = e
}
val request = "${subscriptionEntity.serviceId}:${subscriptionEntity.url}" val request = "${subscriptionEntity.serviceId}:${subscriptionEntity.url}"
val wrapper = RequestException(subscriptionEntity.uid, request, e) val wrapper = RequestException(subscriptionEntity.uid, request, error!!)
return@map Notification.createOnError<Pair<Long, ListInfo<StreamInfoItem>>>(wrapper) return@map Notification.createOnError<Pair<Long, ListInfo<StreamInfoItem>>>(wrapper)
} }
} }
.sequential() .sequential()
.observeOn(AndroidSchedulers.mainThread())
.doOnNext(errorHandlingConsumer)
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.doOnNext(notificationsConsumer) .doOnNext(notificationsConsumer)
@ -331,24 +339,6 @@ class FeedLoadService : Service() {
} }
} }
private val errorHandlingConsumer: Consumer<Notification<Pair<Long, ListInfo<StreamInfoItem>>>>
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<Notification<Pair<Long, ListInfo<StreamInfoItem>>>> private val notificationsConsumer: Consumer<Notification<Pair<Long, ListInfo<StreamInfoItem>>>>
get() = Consumer { onItemCompleted(it.value?.second?.name) } get() = Consumer { onItemCompleted(it.value?.second?.name) }

View file

@ -211,11 +211,11 @@ public class HistoryRecordManager {
public Maybe<StreamStateEntity> loadStreamState(final PlayQueueItem queueItem) { public Maybe<StreamStateEntity> loadStreamState(final PlayQueueItem queueItem) {
return queueItem.getStream() return queueItem.getStream()
.map((info) -> streamTable.upsert(new StreamEntity(info))) .map(info -> streamTable.upsert(new StreamEntity(info)))
.flatMapPublisher(streamStateTable::getState) .flatMapPublisher(streamStateTable::getState)
.firstElement() .firstElement()
.flatMap(list -> list.isEmpty() ? Maybe.empty() : Maybe.just(list.get(0))) .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()); .subscribeOn(Schedulers.io());
} }
@ -224,18 +224,16 @@ public class HistoryRecordManager {
.flatMapPublisher(streamStateTable::getState) .flatMapPublisher(streamStateTable::getState)
.firstElement() .firstElement()
.flatMap(list -> list.isEmpty() ? Maybe.empty() : Maybe.just(list.get(0))) .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()); .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(() -> { return Completable.fromAction(() -> database.runInTransaction(() -> {
final long streamId = streamTable.upsert(new StreamEntity(info)); final long streamId = streamTable.upsert(new StreamEntity(info));
final StreamStateEntity state = new StreamStateEntity(streamId, progressTime); final StreamStateEntity state = new StreamStateEntity(streamId, progressMillis);
if (state.isValid((int) info.getDuration())) { if (state.isValid(info.getDuration())) {
streamStateTable.upsert(state); streamStateTable.upsert(state);
} else {
streamStateTable.deleteState(streamId);
} }
})).subscribeOn(Schedulers.io()); })).subscribeOn(Schedulers.io());
} }

View file

@ -36,7 +36,7 @@ import org.schabi.newpipe.player.helper.PlayerHolder;
import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.SinglePlayQueue; import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
import org.schabi.newpipe.settings.HistorySettingsFragment; 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.NavigationHelper;
import org.schabi.newpipe.util.OnClickGesture; import org.schabi.newpipe.util.OnClickGesture;
import org.schabi.newpipe.util.StreamDialogEntry; import org.schabi.newpipe.util.StreamDialogEntry;
@ -111,7 +111,8 @@ public class StatisticsPlaylistFragment
} }
@Override @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); super.onCreateOptionsMenu(menu, inflater);
inflater.inflate(R.menu.menu_history, menu); inflater.inflate(R.menu.menu_history, menu);
} }
@ -359,7 +360,7 @@ public class StatisticsPlaylistFragment
)); ));
} }
entries.add(StreamDialogEntry.open_in_browser); entries.add(StreamDialogEntry.open_in_browser);
if (KoreUtil.shouldShowPlayWithKodi(context, infoItem.getServiceId())) { if (KoreUtils.shouldShowPlayWithKodi(context, infoItem.getServiceId())) {
entries.add(StreamDialogEntry.play_with_kodi); entries.add(StreamDialogEntry.play_with_kodi);
} }

View file

@ -68,11 +68,11 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder {
R.color.duration_background_color)); R.color.duration_background_color));
itemDurationView.setVisibility(View.VISIBLE); itemDurationView.setVisibility(View.VISIBLE);
if (item.getProgressTime() > 0) { if (item.getProgressMillis() > 0) {
itemProgressView.setVisibility(View.VISIBLE); itemProgressView.setVisibility(View.VISIBLE);
itemProgressView.setMax((int) item.getStreamEntity().getDuration()); itemProgressView.setMax((int) item.getStreamEntity().getDuration());
itemProgressView.setProgress((int) TimeUnit.MILLISECONDS itemProgressView.setProgress((int) TimeUnit.MILLISECONDS
.toSeconds(item.getProgressTime())); .toSeconds(item.getProgressMillis()));
} else { } else {
itemProgressView.setVisibility(View.GONE); itemProgressView.setVisibility(View.GONE);
} }
@ -109,14 +109,14 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder {
} }
final PlaylistStreamEntry item = (PlaylistStreamEntry) 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()); itemProgressView.setMax((int) item.getStreamEntity().getDuration());
if (itemProgressView.getVisibility() == View.VISIBLE) { if (itemProgressView.getVisibility() == View.VISIBLE) {
itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS
.toSeconds(item.getProgressTime())); .toSeconds(item.getProgressMillis()));
} else { } else {
itemProgressView.setProgress((int) TimeUnit.MILLISECONDS itemProgressView.setProgress((int) TimeUnit.MILLISECONDS
.toSeconds(item.getProgressTime())); .toSeconds(item.getProgressMillis()));
ViewUtils.animate(itemProgressView, true, 500); ViewUtils.animate(itemProgressView, true, 500);
} }
} else if (itemProgressView.getVisibility() == View.VISIBLE) { } else if (itemProgressView.getVisibility() == View.VISIBLE) {

View file

@ -96,11 +96,11 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder {
R.color.duration_background_color)); R.color.duration_background_color));
itemDurationView.setVisibility(View.VISIBLE); itemDurationView.setVisibility(View.VISIBLE);
if (item.getProgressTime() > 0) { if (item.getProgressMillis() > 0) {
itemProgressView.setVisibility(View.VISIBLE); itemProgressView.setVisibility(View.VISIBLE);
itemProgressView.setMax((int) item.getStreamEntity().getDuration()); itemProgressView.setMax((int) item.getStreamEntity().getDuration());
itemProgressView.setProgress((int) TimeUnit.MILLISECONDS itemProgressView.setProgress((int) TimeUnit.MILLISECONDS
.toSeconds(item.getProgressTime())); .toSeconds(item.getProgressMillis()));
} else { } else {
itemProgressView.setVisibility(View.GONE); itemProgressView.setVisibility(View.GONE);
} }
@ -140,14 +140,14 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder {
} }
final StreamStatisticsEntry item = (StreamStatisticsEntry) 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()); itemProgressView.setMax((int) item.getStreamEntity().getDuration());
if (itemProgressView.getVisibility() == View.VISIBLE) { if (itemProgressView.getVisibility() == View.VISIBLE) {
itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS
.toSeconds(item.getProgressTime())); .toSeconds(item.getProgressMillis()));
} else { } else {
itemProgressView.setProgress((int) TimeUnit.MILLISECONDS itemProgressView.setProgress((int) TimeUnit.MILLISECONDS
.toSeconds(item.getProgressTime())); .toSeconds(item.getProgressMillis()));
ViewUtils.animate(itemProgressView, true, 500); ViewUtils.animate(itemProgressView, true, 500);
} }
} else if (itemProgressView.getVisibility() == View.VISIBLE) { } else if (itemProgressView.getVisibility() == View.VISIBLE) {

View file

@ -44,7 +44,7 @@ import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.player.helper.PlayerHolder; import org.schabi.newpipe.player.helper.PlayerHolder;
import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.SinglePlayQueue; 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.Localization;
import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.OnClickGesture; import org.schabi.newpipe.util.OnClickGesture;
@ -68,6 +68,7 @@ import io.reactivex.rxjava3.subjects.PublishSubject;
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
import static org.schabi.newpipe.ktx.ViewUtils.animate; import static org.schabi.newpipe.ktx.ViewUtils.animate;
import static org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout;
public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistStreamEntry>, Void> { public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistStreamEntry>, Void> {
// Save the list 10 seconds after the last change occurred // Save the list 10 seconds after the last change occurred
@ -248,7 +249,8 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
} }
@Override @Override
public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { public void onCreateOptionsMenu(@NonNull final Menu menu,
@NonNull final MenuInflater inflater) {
if (DEBUG) { if (DEBUG) {
Log.d(TAG, "onCreateOptionsMenu() called with: " Log.d(TAG, "onCreateOptionsMenu() called with: "
+ "menu = [" + menu + "], inflater = [" + inflater + "]"); + "menu = [" + menu + "], inflater = [" + inflater + "]");
@ -677,7 +679,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
private ItemTouchHelper.SimpleCallback getItemTouchCallback() { private ItemTouchHelper.SimpleCallback getItemTouchCallback() {
int directions = ItemTouchHelper.UP | ItemTouchHelper.DOWN; int directions = ItemTouchHelper.UP | ItemTouchHelper.DOWN;
if (isGridLayout()) { if (shouldUseGridLayout(requireContext())) {
directions |= ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT; directions |= ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT;
} }
return new ItemTouchHelper.SimpleCallback(directions, return new ItemTouchHelper.SimpleCallback(directions,
@ -770,7 +772,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
)); ));
} }
entries.add(StreamDialogEntry.open_in_browser); entries.add(StreamDialogEntry.open_in_browser);
if (KoreUtil.shouldShowPlayWithKodi(context, infoItem.getServiceId())) { if (KoreUtils.shouldShowPlayWithKodi(context, infoItem.getServiceId())) {
entries.add(StreamDialogEntry.play_with_kodi); entries.add(StreamDialogEntry.play_with_kodi);
} }

View file

@ -23,13 +23,9 @@ public class ImportConfirmationDialog extends DialogFragment {
public static void show(@NonNull final Fragment fragment, public static void show(@NonNull final Fragment fragment,
@NonNull final Intent resultServiceIntent) { @NonNull final Intent resultServiceIntent) {
if (fragment.getFragmentManager() == null) {
return;
}
final ImportConfirmationDialog confirmationDialog = new ImportConfirmationDialog(); final ImportConfirmationDialog confirmationDialog = new ImportConfirmationDialog();
confirmationDialog.setResultServiceIntent(resultServiceIntent); confirmationDialog.setResultServiceIntent(resultServiceIntent);
confirmationDialog.show(fragment.getFragmentManager(), null); confirmationDialog.show(fragment.getParentFragmentManager(), null);
} }
public void setResultServiceIntent(final Intent resultServiceIntent) { public void setResultServiceIntent(final Intent resultServiceIntent) {

View file

@ -6,9 +6,7 @@ import android.content.Context
import android.content.DialogInterface import android.content.DialogInterface
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.res.Configuration
import android.os.Bundle import android.os.Bundle
import android.os.Environment
import android.os.Parcelable import android.os.Parcelable
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.Menu import android.view.Menu
@ -16,12 +14,12 @@ import android.view.MenuInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import com.nononsenseapps.filepicker.Utils
import com.xwray.groupie.Group import com.xwray.groupie.Group
import com.xwray.groupie.GroupAdapter import com.xwray.groupie.GroupAdapter
import com.xwray.groupie.Item import com.xwray.groupie.Item
@ -52,22 +50,20 @@ 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.item.HeaderWithMenuItem.Companion.PAYLOAD_UPDATE_VISIBILITY_MENU_ITEM
import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService 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.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 org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.IMPORT_COMPLETE_ACTION 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_MODE
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE 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.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.NavigationHelper
import org.schabi.newpipe.util.OnClickGesture import org.schabi.newpipe.util.OnClickGesture
import org.schabi.newpipe.util.ShareUtils import org.schabi.newpipe.util.ThemeHelper.getGridSpanCount
import java.io.File import org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout
import org.schabi.newpipe.util.external_communication.ShareUtils
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
import kotlin.math.floor
import kotlin.math.max
class SubscriptionFragment : BaseStateFragment<SubscriptionState>() { class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
private var _binding: FragmentSubscriptionBinding? = null private var _binding: FragmentSubscriptionBinding? = null
@ -86,6 +82,11 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
private lateinit var feedGroupsSortMenuItem: HeaderWithMenuItem private lateinit var feedGroupsSortMenuItem: HeaderWithMenuItem
private val subscriptionsSection = Section() private val subscriptionsSection = Section()
private val requestExportLauncher =
registerForActivityResult(StartActivityForResult(), this::requestExportResult)
private val requestImportLauncher =
registerForActivityResult(StartActivityForResult(), this::requestImportResult)
@State @State
@JvmField @JvmField
var itemsListState: Parcelable? = null var itemsListState: Parcelable? = null
@ -188,46 +189,41 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
} }
private fun onImportPreviousSelected() { private fun onImportPreviousSelected() {
startActivityForResult(FilePickerActivityHelper.chooseSingleFile(activity), REQUEST_IMPORT_CODE) requestImportLauncher.launch(StoredFileHelper.getPicker(activity))
} }
private fun onExportSelected() { private fun onExportSelected() {
val date = SimpleDateFormat("yyyyMMddHHmm", Locale.ENGLISH).format(Date()) val date = SimpleDateFormat("yyyyMMddHHmm", Locale.ENGLISH).format(Date())
val exportName = "newpipe_subscriptions_$date.json" 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() { private fun openReorderDialog() {
FeedGroupReorderDialog().show(requireFragmentManager(), null) FeedGroupReorderDialog().show(parentFragmentManager, null)
} }
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { fun requestExportResult(result: ActivityResult) {
super.onActivityResult(requestCode, resultCode, data) if (result.data != null && result.resultCode == Activity.RESULT_OK) {
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( activity.startService(
Intent(activity, SubscriptionsExportService::class.java) Intent(activity, SubscriptionsExportService::class.java)
.putExtra(KEY_FILE_PATH, exportFile.absolutePath) .putExtra(SubscriptionsExportService.KEY_FILE_PATH, result.data?.data)
) )
} }
} else if (requestCode == REQUEST_IMPORT_CODE) { }
val path = Utils.getFileForUri(data.data!!).absolutePath
fun requestImportResult(result: ActivityResult) {
if (result.data != null && result.resultCode == Activity.RESULT_OK) {
ImportConfirmationDialog.show( ImportConfirmationDialog.show(
this, this,
Intent(activity, SubscriptionsImportService::class.java) Intent(activity, SubscriptionsImportService::class.java)
.putExtra(KEY_MODE, PREVIOUS_EXPORT_MODE) .putExtra(KEY_MODE, PREVIOUS_EXPORT_MODE)
.putExtra(KEY_VALUE, path) .putExtra(KEY_VALUE, result.data?.data)
) )
} }
} }
}
// //////////////////////////////////////////////////////////////////////// // ////////////////////////////////////////////////////////////////////////
// Fragment Views // Fragment Views
@ -281,8 +277,7 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
super.initViews(rootView, savedInstanceState) super.initViews(rootView, savedInstanceState)
_binding = FragmentSubscriptionBinding.bind(rootView) _binding = FragmentSubscriptionBinding.bind(rootView)
val shouldUseGridLayout = shouldUseGridLayout() groupAdapter.spanCount = if (shouldUseGridLayout(context)) getGridSpanCount(context) else 1
groupAdapter.spanCount = if (shouldUseGridLayout) getGridSpanCount() else 1
binding.itemsList.layoutManager = GridLayoutManager(requireContext(), groupAdapter.spanCount).apply { binding.itemsList.layoutManager = GridLayoutManager(requireContext(), groupAdapter.spanCount).apply {
spanSizeLookup = groupAdapter.spanSizeLookup spanSizeLookup = groupAdapter.spanSizeLookup
} }
@ -295,13 +290,17 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
private fun showLongTapDialog(selectedItem: ChannelInfoItem) { private fun showLongTapDialog(selectedItem: ChannelInfoItem) {
val commands = arrayOf( 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) getString(R.string.unsubscribe)
) )
val actions = DialogInterface.OnClickListener { _, i -> val actions = DialogInterface.OnClickListener { _, i ->
when (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) 1 -> ShareUtils.openUrlInBrowser(requireContext(), selectedItem.url)
2 -> deleteChannel(selectedItem) 2 -> deleteChannel(selectedItem)
} }
@ -357,7 +356,7 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
override fun handleResult(result: SubscriptionState) { override fun handleResult(result: SubscriptionState) {
super.handleResult(result) super.handleResult(result)
val shouldUseGridLayout = shouldUseGridLayout() val shouldUseGridLayout = shouldUseGridLayout(context)
when (result) { when (result) {
is SubscriptionState.LoadedState -> { is SubscriptionState.LoadedState -> {
result.subscriptions.forEach { result.subscriptions.forEach {
@ -418,35 +417,4 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
super.hideLoading() super.hideLoading()
binding.itemsList.animate(true, 200) binding.itemsList.animate(true, 200)
} }
// /////////////////////////////////////////////////////////////////////////
// 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.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
}
} }

View file

@ -12,14 +12,15 @@ import android.widget.Button;
import android.widget.EditText; import android.widget.EditText;
import android.widget.TextView; 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.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.annotation.StringRes; import androidx.annotation.StringRes;
import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.ActionBar;
import androidx.core.text.util.LinkifyCompat; import androidx.core.text.util.LinkifyCompat;
import com.nononsenseapps.filepicker.Utils;
import org.schabi.newpipe.BaseFragment; import org.schabi.newpipe.BaseFragment;
import org.schabi.newpipe.R; import org.schabi.newpipe.R;
import org.schabi.newpipe.error.ErrorActivity; import org.schabi.newpipe.error.ErrorActivity;
@ -29,8 +30,8 @@ import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.exceptions.ExtractionException;
import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor; import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor;
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService; 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.Constants;
import org.schabi.newpipe.util.FilePickerActivityHelper;
import org.schabi.newpipe.util.ServiceHelper; import org.schabi.newpipe.util.ServiceHelper;
import java.util.Collections; import java.util.Collections;
@ -45,8 +46,6 @@ import static org.schabi.newpipe.local.subscription.services.SubscriptionsImport
import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE; import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE;
public class SubscriptionsImportFragment extends BaseFragment { public class SubscriptionsImportFragment extends BaseFragment {
private static final int REQUEST_IMPORT_FILE_CODE = 666;
@State @State
int currentServiceId = Constants.NO_SERVICE_ID; int currentServiceId = Constants.NO_SERVICE_ID;
@ -64,6 +63,9 @@ public class SubscriptionsImportFragment extends BaseFragment {
private EditText inputText; private EditText inputText;
private Button inputButton; private Button inputButton;
private final ActivityResultLauncher<Intent> requestImportFileLauncher =
registerForActivityResult(new StartActivityForResult(), this::requestImportFileResult);
public static SubscriptionsImportFragment getInstance(final int serviceId) { public static SubscriptionsImportFragment getInstance(final int serviceId) {
final SubscriptionsImportFragment instance = new SubscriptionsImportFragment(); final SubscriptionsImportFragment instance = new SubscriptionsImportFragment();
instance.setInitialData(serviceId); instance.setInitialData(serviceId);
@ -175,23 +177,19 @@ public class SubscriptionsImportFragment extends BaseFragment {
} }
public void onImportFile() { public void onImportFile() {
startActivityForResult(FilePickerActivityHelper.chooseSingleFile(activity), requestImportFileLauncher.launch(StoredFileHelper.getPicker(activity));
REQUEST_IMPORT_FILE_CODE);
} }
@Override private void requestImportFileResult(final ActivityResult result) {
public void onActivityResult(final int requestCode, final int resultCode, final Intent data) { if (result.getData() == null) {
super.onActivityResult(requestCode, resultCode, data);
if (data == null) {
return; return;
} }
if (resultCode == Activity.RESULT_OK && requestCode == REQUEST_IMPORT_FILE_CODE if (result.getResultCode() == Activity.RESULT_OK && result.getData().getData() != null) {
&& data.getData() != null) {
final String path = Utils.getFileForUri(data.getData()).getAbsolutePath();
ImportConfirmationDialog.show(this, ImportConfirmationDialog.show(this,
new Intent(activity, SubscriptionsImportService.class) 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)); .putExtra(Constants.KEY_SERVICE_ID, currentServiceId));
} }
} }

View file

@ -20,7 +20,7 @@
package org.schabi.newpipe.local.subscription.services; package org.schabi.newpipe.local.subscription.services;
import android.content.Intent; import android.content.Intent;
import android.text.TextUtils; import android.net.Uri;
import android.util.Log; import android.util.Log;
import androidx.localbroadcastmanager.content.LocalBroadcastManager; import androidx.localbroadcastmanager.content.LocalBroadcastManager;
@ -31,10 +31,11 @@ import org.schabi.newpipe.App;
import org.schabi.newpipe.R; import org.schabi.newpipe.R;
import org.schabi.newpipe.database.subscription.SubscriptionEntity; import org.schabi.newpipe.database.subscription.SubscriptionEntity;
import org.schabi.newpipe.extractor.subscription.SubscriptionItem; 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.IOException;
import java.io.FileNotFoundException; import java.io.OutputStream;
import java.io.FileOutputStream;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@ -55,8 +56,8 @@ public class SubscriptionsExportService extends BaseImportExportService {
+ ".services.SubscriptionsExportService.EXPORT_COMPLETE"; + ".services.SubscriptionsExportService.EXPORT_COMPLETE";
private Subscription subscription; private Subscription subscription;
private File outFile; private StoredFileHelper outFile;
private FileOutputStream outputStream; private OutputStream outputStream;
@Override @Override
public int onStartCommand(final Intent intent, final int flags, final int startId) { public int onStartCommand(final Intent intent, final int flags, final int startId) {
@ -64,18 +65,18 @@ public class SubscriptionsExportService extends BaseImportExportService {
return START_NOT_STICKY; return START_NOT_STICKY;
} }
final String path = intent.getStringExtra(KEY_FILE_PATH); final Uri path = intent.getParcelableExtra(KEY_FILE_PATH);
if (TextUtils.isEmpty(path)) { if (path == null) {
stopAndReportError(new IllegalStateException( 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"); "Exporting subscriptions");
return START_NOT_STICKY; return START_NOT_STICKY;
} }
try { try {
outFile = new File(path); outFile = new StoredFileHelper(this, path, "application/json");
outputStream = new FileOutputStream(outFile); outputStream = new SharpOutputStream(outFile.getStream());
} catch (final FileNotFoundException e) { } catch (final IOException e) {
handleError(e); handleError(e);
return START_NOT_STICKY; return START_NOT_STICKY;
} }
@ -122,8 +123,8 @@ public class SubscriptionsExportService extends BaseImportExportService {
.subscribe(getSubscriber()); .subscribe(getSubscriber());
} }
private Subscriber<File> getSubscriber() { private Subscriber<StoredFileHelper> getSubscriber() {
return new Subscriber<File>() { return new Subscriber<StoredFileHelper>() {
@Override @Override
public void onSubscribe(final Subscription s) { public void onSubscribe(final Subscription s) {
subscription = s; subscription = s;
@ -131,7 +132,7 @@ public class SubscriptionsExportService extends BaseImportExportService {
} }
@Override @Override
public void onNext(final File file) { public void onNext(final StoredFileHelper file) {
if (DEBUG) { if (DEBUG) {
Log.d(TAG, "startExport() success: file = " + file); Log.d(TAG, "startExport() success: file = " + file);
} }
@ -153,7 +154,7 @@ public class SubscriptionsExportService extends BaseImportExportService {
}; };
} }
private Function<List<SubscriptionItem>, File> exportToFile() { private Function<List<SubscriptionItem>, StoredFileHelper> exportToFile() {
return subscriptionItems -> { return subscriptionItems -> {
ImportExportJsonHelper.writeTo(subscriptionItems, outputStream, eventListener); ImportExportJsonHelper.writeTo(subscriptionItems, outputStream, eventListener);
return outFile; return outFile;

View file

@ -20,6 +20,7 @@
package org.schabi.newpipe.local.subscription.services; package org.schabi.newpipe.local.subscription.services;
import android.content.Intent; import android.content.Intent;
import android.net.Uri;
import android.text.TextUtils; import android.text.TextUtils;
import android.util.Log; import android.util.Log;
@ -36,12 +37,11 @@ import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.extractor.channel.ChannelInfo;
import org.schabi.newpipe.extractor.subscription.SubscriptionItem; import org.schabi.newpipe.extractor.subscription.SubscriptionItem;
import org.schabi.newpipe.ktx.ExceptionUtils; 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.Constants;
import org.schabi.newpipe.util.ExtractorHelper; 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.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.util.ArrayList; import java.util.ArrayList;
@ -55,6 +55,7 @@ import io.reactivex.rxjava3.functions.Function;
import io.reactivex.rxjava3.schedulers.Schedulers; import io.reactivex.rxjava3.schedulers.Schedulers;
import static org.schabi.newpipe.MainActivity.DEBUG; import static org.schabi.newpipe.MainActivity.DEBUG;
import static org.schabi.newpipe.streams.io.StoredFileHelper.DEFAULT_MIME;
public class SubscriptionsImportService extends BaseImportExportService { public class SubscriptionsImportService extends BaseImportExportService {
public static final int CHANNEL_URL_MODE = 0; public static final int CHANNEL_URL_MODE = 0;
@ -101,17 +102,18 @@ public class SubscriptionsImportService extends BaseImportExportService {
if (currentMode == CHANNEL_URL_MODE) { if (currentMode == CHANNEL_URL_MODE) {
channelUrl = intent.getStringExtra(KEY_VALUE); channelUrl = intent.getStringExtra(KEY_VALUE);
} else { } else {
final String filePath = intent.getStringExtra(KEY_VALUE); final Uri uri = intent.getParcelableExtra(KEY_VALUE);
if (TextUtils.isEmpty(filePath)) { if (uri == null) {
stopAndReportError(new IllegalStateException( 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"); "Importing subscriptions");
return START_NOT_STICKY; return START_NOT_STICKY;
} }
try { try {
inputStream = new FileInputStream(new File(filePath)); inputStream = new SharpInputStream(
} catch (final FileNotFoundException e) { new StoredFileHelper(this, uri, DEFAULT_MIME).getStream());
} catch (final IOException e) {
handleError(e); handleError(e);
return START_NOT_STICKY; return START_NOT_STICKY;
} }

View file

@ -47,7 +47,7 @@ import java.util.List;
import static org.schabi.newpipe.player.helper.PlayerHelper.formatSpeed; import static org.schabi.newpipe.player.helper.PlayerHelper.formatSpeed;
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; 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 public final class PlayQueueActivity extends AppCompatActivity
implements PlayerEventListener, SeekBar.OnSeekBarChangeListener, implements PlayerEventListener, SeekBar.OnSeekBarChangeListener,
@ -313,7 +313,8 @@ public final class PlayQueueActivity extends AppCompatActivity
final MenuItem share = popupMenu.getMenu().add(RECYCLER_ITEM_POPUP_MENU_GROUP_ID, 3, final MenuItem share = popupMenu.getMenu().add(RECYCLER_ITEM_POPUP_MENU_GROUP_ID, 3,
Menu.NONE, R.string.share); Menu.NONE, R.string.share);
share.setOnMenuItemClickListener(menuItem -> { share.setOnMenuItemClickListener(menuItem -> {
shareText(getApplicationContext(), item.getTitle(), item.getUrl()); shareText(getApplicationContext(), item.getTitle(), item.getUrl(),
item.getThumbnailUrl());
return true; return true;
}); });

View file

@ -124,11 +124,11 @@ import org.schabi.newpipe.player.resolver.MediaSourceTag;
import org.schabi.newpipe.player.resolver.VideoPlaybackResolver; import org.schabi.newpipe.player.resolver.VideoPlaybackResolver;
import org.schabi.newpipe.util.DeviceUtils; import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.ImageDisplayConstants; 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.ListHelper;
import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.SerializedCache; import org.schabi.newpipe.util.SerializedCache;
import org.schabi.newpipe.util.ShareUtils; import org.schabi.newpipe.util.external_communication.ShareUtils;
import org.schabi.newpipe.util.SponsorBlockMode; import org.schabi.newpipe.util.SponsorBlockMode;
import org.schabi.newpipe.util.VideoSegment; import org.schabi.newpipe.util.VideoSegment;
import org.schabi.newpipe.views.ExpandableSurfaceView; import org.schabi.newpipe.views.ExpandableSurfaceView;
@ -544,6 +544,7 @@ public final class Player implements
binding.moreOptionsButton.setOnClickListener(this); binding.moreOptionsButton.setOnClickListener(this);
binding.moreOptionsButton.setOnLongClickListener(this); binding.moreOptionsButton.setOnLongClickListener(this);
binding.share.setOnClickListener(this); binding.share.setOnClickListener(this);
binding.share.setOnLongClickListener(this);
binding.fullScreenButton.setOnClickListener(this); binding.fullScreenButton.setOnClickListener(this);
binding.screenRotationButton.setOnClickListener(this); binding.screenRotationButton.setOnClickListener(this);
binding.playWithKodi.setOnClickListener(this); binding.playWithKodi.setOnClickListener(this);
@ -685,7 +686,11 @@ public final class Player implements
//.doFinally() //.doFinally()
.subscribe( .subscribe(
state -> { 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, initPlayback(newQueue, repeatMode, playbackSpeed, playbackPitch,
playbackSkipSilence, playWhenReady, isMuted); playbackSkipSilence, playWhenReady, isMuted);
}, },
@ -1055,7 +1060,7 @@ public final class Player implements
// show kodi button if it supports the current service and it is enabled in settings // show kodi button if it supports the current service and it is enabled in settings
binding.playWithKodi.setVisibility(videoPlayerSelected() binding.playWithKodi.setVisibility(videoPlayerSelected()
&& playQueue != null && playQueue.getItem() != null && playQueue != null && playQueue.getItem() != null
&& KoreUtil.shouldShowPlayWithKodi(context, playQueue.getItem().getServiceId()) && KoreUtils.shouldShowPlayWithKodi(context, playQueue.getItem().getServiceId())
? View.VISIBLE : View.GONE); ? View.VISIBLE : View.GONE);
} }
//endregion //endregion
@ -2031,9 +2036,7 @@ public final class Player implements
break; break;
case com.google.android.exoplayer2.Player.STATE_ENDED: // 4 case com.google.android.exoplayer2.Player.STATE_ENDED: // 4
changeState(STATE_COMPLETED); changeState(STATE_COMPLETED);
if (currentMetadata != null) { saveStreamProgressStateCompleted();
resetStreamProgressState(currentMetadata.getMetadata());
}
isPrepared = false; isPrepared = false;
break; break;
} }
@ -2256,7 +2259,10 @@ public final class Player implements
private void onCompleted() { private void onCompleted() {
if (DEBUG) { 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, animate(binding.playPauseButton, false, 0, AnimationType.SCALE_AND_ALPHA, 0,
@ -2496,7 +2502,7 @@ public final class Player implements
case DISCONTINUITY_REASON_SEEK_ADJUSTMENT: case DISCONTINUITY_REASON_SEEK_ADJUSTMENT:
case DISCONTINUITY_REASON_INTERNAL: case DISCONTINUITY_REASON_INTERNAL:
if (playQueue.getIndex() != newWindowIndex) { if (playQueue.getIndex() != newWindowIndex) {
resetStreamProgressState(playQueue.getItem()); saveStreamProgressStateCompleted(); // current stream has ended
playQueue.setIndex(newWindowIndex); playQueue.setIndex(newWindowIndex);
} }
break; break;
@ -2887,15 +2893,18 @@ public final class Player implements
} }
} }
private void saveStreamProgressState(final StreamInfo info, final long progress) { private void saveStreamProgressState(final long progressMillis) {
if (info == null) { if (currentMetadata == null
|| !prefs.getBoolean(context.getString(R.string.enable_watch_history_key), true)) {
return; return;
} }
if (DEBUG) { if (DEBUG) {
Log.d(TAG, "saveStreamProgressState() called"); 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 = recordManager.saveStreamState(info, progress) databaseUpdateDisposable
.add(recordManager.saveStreamState(currentMetadata.getMetadata(), progressMillis)
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.doOnError((e) -> { .doOnError((e) -> {
if (DEBUG) { if (DEBUG) {
@ -2903,45 +2912,28 @@ public final class Player implements
} }
}) })
.onErrorComplete() .onErrorComplete()
.subscribe(); .subscribe());
databaseUpdateDisposable.add(stateSaver);
}
}
private void resetStreamProgressState(final PlayQueueItem queueItem) {
if (queueItem == null) {
return;
}
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);
} }
public void saveStreamProgressState() { 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; return;
} }
final StreamInfo currentInfo = currentMetadata.getMetadata();
if (playQueue != null) {
// Save current position. It will help to restore this position once a user // Save current position. It will help to restore this position once a user
// wants to play prev or next stream from the queue // wants to play prev or next stream from the queue
playQueue.setRecovery(playQueue.getIndex(), simpleExoPlayer.getContentPosition()); 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 //endregion
@ -3031,6 +3023,18 @@ public final class Player implements
: currentMetadata.getMetadata().getUrl(); : 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 @NonNull
public String getVideoTitle() { public String getVideoTitle() {
return currentMetadata == null return currentMetadata == null
@ -3694,7 +3698,8 @@ public final class Player implements
} else if (v.getId() == binding.moreOptionsButton.getId()) { } else if (v.getId() == binding.moreOptionsButton.getId()) {
onMoreOptionsClicked(); onMoreOptionsClicked();
} else if (v.getId() == binding.share.getId()) { } else if (v.getId() == binding.share.getId()) {
onShareClicked(); ShareUtils.shareText(context, getVideoTitle(), getVideoUrlAtCurrentTime(),
currentItem.getThumbnailUrl());
} else if (v.getId() == binding.playWithKodi.getId()) { } else if (v.getId() == binding.playWithKodi.getId()) {
onPlayWithKodiClicked(); onPlayWithKodiClicked();
} else if (v.getId() == binding.openInBrowser.getId()) { } else if (v.getId() == binding.openInBrowser.getId()) {
@ -3772,6 +3777,8 @@ public final class Player implements
setBlockSponsorsButton(binding.switchSponsorBlocking); setBlockSponsorsButton(binding.switchSponsorBlocking);
Toast.makeText(context, toastText, Toast.LENGTH_LONG).show(); Toast.makeText(context, toastText, Toast.LENGTH_LONG).show();
} else if (v.getId() == binding.share.getId()) {
ShareUtils.copyToClipboard(context, getVideoUrlAtCurrentTime());
} }
return true; return true;
} }
@ -3843,19 +3850,6 @@ public final class Player implements
showControls(DEFAULT_CONTROLS_DURATION); 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() { private void onPlayWithKodiClicked() {
if (currentMetadata != null) { if (currentMetadata != null) {
pause(); pause();
@ -3865,7 +3859,7 @@ public final class Player implements
if (DEBUG) { if (DEBUG) {
Log.i(TAG, "Failed to start kore", e); Log.i(TAG, "Failed to start kore", e);
} }
KoreUtil.showInstallKoreDialog(getParentActivity()); KoreUtils.showInstallKoreDialog(getParentActivity());
} }
} }
} }

View file

@ -182,8 +182,10 @@ public class PlayQueueAdapter extends RecyclerView.Adapter<RecyclerView.ViewHold
return ITEM_VIEW_TYPE_ID; return ITEM_VIEW_TYPE_ID;
} }
@NonNull
@Override @Override
public RecyclerView.ViewHolder onCreateViewHolder(final ViewGroup parent, final int type) { public RecyclerView.ViewHolder onCreateViewHolder(@NonNull final ViewGroup parent,
final int type) {
switch (type) { switch (type) {
case FOOTER_VIEW_TYPE_ID: case FOOTER_VIEW_TYPE_ID:
return new HFHolder(footer); return new HFHolder(footer);
@ -197,7 +199,8 @@ public class PlayQueueAdapter extends RecyclerView.Adapter<RecyclerView.ViewHold
} }
@Override @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) { if (holder instanceof PlayQueueItemHolder) {
final PlayQueueItemHolder itemHolder = (PlayQueueItemHolder) holder; final PlayQueueItemHolder itemHolder = (PlayQueueItemHolder) holder;
@ -207,7 +210,6 @@ public class PlayQueueAdapter extends RecyclerView.Adapter<RecyclerView.ViewHold
// Check if the current item should be selected/highlighted // Check if the current item should be selected/highlighted
final boolean isSelected = playQueue.getIndex() == position; final boolean isSelected = playQueue.getIndex() == position;
itemHolder.itemSelected.setVisibility(isSelected ? View.VISIBLE : View.INVISIBLE);
itemHolder.itemView.setSelected(isSelected); itemHolder.itemView.setSelected(isSelected);
} else if (holder instanceof HFHolder && position == playQueue.getStreams().size() } else if (holder instanceof HFHolder && position == playQueue.getStreams().size()
&& footer != null && showFooter) { && footer != null && showFooter) {

View file

@ -37,7 +37,6 @@ public class PlayQueueItemHolder extends RecyclerView.ViewHolder {
public final TextView itemDurationView; public final TextView itemDurationView;
final TextView itemAdditionalDetailsView; final TextView itemAdditionalDetailsView;
final ImageView itemSelected;
public final ImageView itemThumbnailView; public final ImageView itemThumbnailView;
final ImageView itemHandle; final ImageView itemHandle;
@ -49,7 +48,6 @@ public class PlayQueueItemHolder extends RecyclerView.ViewHolder {
itemVideoTitleView = v.findViewById(R.id.itemVideoTitleView); itemVideoTitleView = v.findViewById(R.id.itemVideoTitleView);
itemDurationView = v.findViewById(R.id.itemDurationView); itemDurationView = v.findViewById(R.id.itemDurationView);
itemAdditionalDetailsView = v.findViewById(R.id.itemAdditionalDetails); itemAdditionalDetailsView = v.findViewById(R.id.itemAdditionalDetails);
itemSelected = v.findViewById(R.id.itemSelected);
itemThumbnailView = v.findViewById(R.id.itemThumbnailView); itemThumbnailView = v.findViewById(R.id.itemThumbnailView);
itemHandle = v.findViewById(R.id.itemHandle); itemHandle = v.findViewById(R.id.itemHandle);
} }

View file

@ -6,12 +6,16 @@ import android.view.View;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.preference.Preference;
import androidx.preference.PreferenceFragmentCompat; import androidx.preference.PreferenceFragmentCompat;
import androidx.preference.PreferenceManager; import androidx.preference.PreferenceManager;
import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.MainActivity;
import org.schabi.newpipe.util.ThemeHelper; import org.schabi.newpipe.util.ThemeHelper;
import java.util.Objects;
public abstract class BasePreferenceFragment extends PreferenceFragmentCompat { public abstract class BasePreferenceFragment extends PreferenceFragmentCompat {
protected final String TAG = getClass().getSimpleName() + "@" + Integer.toHexString(hashCode()); protected final String TAG = getClass().getSimpleName() + "@" + Integer.toHexString(hashCode());
protected final boolean DEBUG = MainActivity.DEBUG; protected final boolean DEBUG = MainActivity.DEBUG;
@ -37,4 +41,11 @@ public abstract class BasePreferenceFragment extends PreferenceFragmentCompat {
super.onResume(); super.onResume();
ThemeHelper.setTitleToAppCompatActivity(getActivity(), getPreferenceScreen().getTitle()); 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;
}
} }

View file

@ -1,21 +1,23 @@
package org.schabi.newpipe.settings; package org.schabi.newpipe.settings;
import android.annotation.SuppressLint;
import android.app.Activity; import android.app.Activity;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import android.net.Uri;
import android.os.Bundle; import android.os.Bundle;
import android.util.Log; import android.util.Log;
import android.widget.Toast; 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.appcompat.app.AlertDialog;
import androidx.core.content.ContextCompat; import androidx.core.content.ContextCompat;
import androidx.preference.Preference; import androidx.preference.Preference;
import androidx.preference.PreferenceManager; import androidx.preference.PreferenceManager;
import com.nononsenseapps.filepicker.Utils;
import com.nostra13.universalimageloader.core.ImageLoader; import com.nostra13.universalimageloader.core.ImageLoader;
import org.schabi.newpipe.DownloaderImpl; import org.schabi.newpipe.DownloaderImpl;
@ -26,74 +28,69 @@ import org.schabi.newpipe.error.ReCaptchaActivity;
import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.localization.ContentCountry; import org.schabi.newpipe.extractor.localization.ContentCountry;
import org.schabi.newpipe.extractor.localization.Localization; import org.schabi.newpipe.extractor.localization.Localization;
import org.schabi.newpipe.util.FilePathUtils; import org.schabi.newpipe.streams.io.StoredFileHelper;
import org.schabi.newpipe.util.FilePickerActivityHelper; import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.ZipHelper; import org.schabi.newpipe.util.ZipHelper;
import java.io.File; import java.io.File;
import java.text.SimpleDateFormat; import java.text.SimpleDateFormat;
import java.util.Date; import java.util.Date;
import java.util.Locale; 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; import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
public class ContentSettingsFragment extends BasePreferenceFragment { public class ContentSettingsFragment extends BasePreferenceFragment {
private static final int REQUEST_IMPORT_PATH = 8945; private static final String ZIP_MIME_TYPE = "application/zip";
private static final int REQUEST_EXPORT_PATH = 30945; private static final SimpleDateFormat EXPORT_DATE_FORMAT
= new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US);
private ContentSettingsManager manager; private ContentSettingsManager manager;
private String importExportDataPathKey; private String importExportDataPathKey;
private String thumbnailLoadToggleKey; private String thumbnailLoadToggleKey;
private String youtubeRestrictedModeEnabledKey; private String youtubeRestrictedModeEnabledKey;
@Nullable private Uri lastImportExportDataUri = null;
private Localization initialSelectedLocalization; private Localization initialSelectedLocalization;
private ContentCountry initialSelectedContentCountry; private ContentCountry initialSelectedContentCountry;
private String initialLanguage; private String initialLanguage;
private final ActivityResultLauncher<Intent> requestImportPathLauncher =
registerForActivityResult(new StartActivityForResult(), this::requestImportPathResult);
private final ActivityResultLauncher<Intent> requestExportPathLauncher =
registerForActivityResult(new StartActivityForResult(), this::requestExportPathResult);
@Override @Override
public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) {
final File homeDir = ContextCompat.getDataDir(requireContext()); final File homeDir = ContextCompat.getDataDir(requireContext());
Objects.requireNonNull(homeDir);
manager = new ContentSettingsManager(new NewPipeFileLocator(homeDir)); manager = new ContentSettingsManager(new NewPipeFileLocator(homeDir));
manager.deleteSettingsFile(); manager.deleteSettingsFile();
addPreferencesFromResource(R.xml.content_settings);
importExportDataPathKey = getString(R.string.import_export_data_path); importExportDataPathKey = getString(R.string.import_export_data_path);
final Preference importDataPreference = findPreference(getString(R.string.import_data));
importDataPreference.setOnPreferenceClickListener(p -> {
final Intent i = new Intent(getActivity(), FilePickerActivityHelper.class)
.putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false)
.putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, false)
.putExtra(FilePickerActivityHelper.EXTRA_MODE,
FilePickerActivityHelper.MODE_FILE);
final String path = defaultPreferences.getString(importExportDataPathKey, "");
if (FilePathUtils.isValidDirectoryPath(path)) {
i.putExtra(FilePickerActivityHelper.EXTRA_START_PATH, path);
}
startActivityForResult(i, REQUEST_IMPORT_PATH);
return true;
});
final Preference exportDataPreference = findPreference(getString(R.string.export_data));
exportDataPreference.setOnPreferenceClickListener(p -> {
final Intent i = new Intent(getActivity(), FilePickerActivityHelper.class)
.putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false)
.putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, true)
.putExtra(FilePickerActivityHelper.EXTRA_MODE,
FilePickerActivityHelper.MODE_DIR);
final String path = defaultPreferences.getString(importExportDataPathKey, "");
if (FilePathUtils.isValidDirectoryPath(path)) {
i.putExtra(FilePickerActivityHelper.EXTRA_START_PATH, path);
}
startActivityForResult(i, REQUEST_EXPORT_PATH);
return true;
});
thumbnailLoadToggleKey = getString(R.string.download_thumbnail_key); thumbnailLoadToggleKey = getString(R.string.download_thumbnail_key);
youtubeRestrictedModeEnabledKey = getString(R.string.youtube_restricted_mode_enabled); youtubeRestrictedModeEnabledKey = getString(R.string.youtube_restricted_mode_enabled);
addPreferencesFromResource(R.xml.content_settings);
final Preference importDataPreference = requirePreference(R.string.import_data);
importDataPreference.setOnPreferenceClickListener((Preference p) -> {
requestImportPathLauncher.launch(
StoredFileHelper.getPicker(requireContext(), getImportExportDataUri()));
return true;
});
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;
});
initialSelectedLocalization = org.schabi.newpipe.util.Localization initialSelectedLocalization = org.schabi.newpipe.util.Localization
.getPreferredLocalization(requireContext()); .getPreferredLocalization(requireContext());
initialSelectedContentCountry = org.schabi.newpipe.util.Localization initialSelectedContentCountry = org.schabi.newpipe.util.Localization
@ -101,8 +98,7 @@ public class ContentSettingsFragment extends BasePreferenceFragment {
initialLanguage = PreferenceManager initialLanguage = PreferenceManager
.getDefaultSharedPreferences(requireContext()).getString("app_language_key", "en"); .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 -> { clearCookiePref.setOnPreferenceClickListener(preference -> {
defaultPreferences.edit() defaultPreferences.edit()
.putString(getString(R.string.recaptcha_cookies_key), "").apply(); .putString(getString(R.string.recaptcha_cookies_key), "").apply();
@ -167,62 +163,56 @@ public class ContentSettingsFragment extends BasePreferenceFragment {
} }
} }
@Override private void requestExportPathResult(final ActivityResult result) {
public void onActivityResult(final int requestCode, final int resultCode,
@NonNull final Intent data) {
assureCorrectAppLanguage(getContext()); assureCorrectAppLanguage(getContext());
super.onActivityResult(requestCode, resultCode, data); if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) {
if (DEBUG) { lastImportExportDataUri = result.getData().getData(); // will be saved only on success
Log.d(TAG, "onActivityResult() called with: "
+ "requestCode = [" + requestCode + "], "
+ "resultCode = [" + resultCode + "], "
+ "data = [" + data + "]");
}
if ((requestCode == REQUEST_IMPORT_PATH || requestCode == REQUEST_EXPORT_PATH) final StoredFileHelper file
&& resultCode == Activity.RESULT_OK && data.getData() != null) { = new StoredFileHelper(getContext(), result.getData().getData(), ZIP_MIME_TYPE);
final File file = Utils.getFileForUri(data.getData());
if (requestCode == REQUEST_EXPORT_PATH) {
exportDatabase(file); 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();
}
} }
} }
private void exportDatabase(@NonNull final File folder) { 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 { try {
final SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US);
final String path = folder.getAbsolutePath() + "/NewPipeData-"
+ sdf.format(new Date()) + ".zip";
//checkpoint before export //checkpoint before export
NewPipeDatabase.checkpoint(); NewPipeDatabase.checkpoint();
final SharedPreferences preferences = PreferenceManager final SharedPreferences preferences = PreferenceManager
.getDefaultSharedPreferences(requireContext()); .getDefaultSharedPreferences(requireContext());
manager.exportDatabase(preferences, path); manager.exportDatabase(preferences, file);
setImportExportDataPath(folder, false);
saveLastImportExportDataUri(false); // save export path only on success
Toast.makeText(getContext(), R.string.export_complete_toast, Toast.LENGTH_SHORT).show(); Toast.makeText(getContext(), R.string.export_complete_toast, Toast.LENGTH_SHORT).show();
} catch (final Exception e) { } catch (final Exception e) {
ErrorActivity.reportUiErrorInSnackbar(this, "Exporting database", e); ErrorActivity.reportUiErrorInSnackbar(this, "Exporting database", e);
} }
} }
private void importDatabase(@NonNull final File file) { private void importDatabase(final StoredFileHelper file) {
final String filePath = file.getAbsolutePath();
// check if file is supported // 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) Toast.makeText(getContext(), R.string.no_valid_zip_file, Toast.LENGTH_SHORT)
.show(); .show();
return; return;
@ -233,29 +223,29 @@ public class ContentSettingsFragment extends BasePreferenceFragment {
throw new Exception("Could not create databases dir"); 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) 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 settings file exist, ask if it should be imported.
if (manager.extractSettings(filePath)) { if (manager.extractSettings(file)) {
final AlertDialog.Builder alert = new AlertDialog.Builder(requireContext()); final AlertDialog.Builder alert = new AlertDialog.Builder(requireContext());
alert.setTitle(R.string.import_settings); alert.setTitle(R.string.import_settings);
alert.setNegativeButton(android.R.string.no, (dialog, which) -> { alert.setNegativeButton(android.R.string.no, (dialog, which) -> {
dialog.dismiss(); dialog.dismiss();
finishImport(file); finishImport();
}); });
alert.setPositiveButton(getString(R.string.finish), (dialog, which) -> { alert.setPositiveButton(getString(R.string.finish), (dialog, which) -> {
dialog.dismiss(); dialog.dismiss();
manager.loadSharedPreferences(PreferenceManager manager.loadSharedPreferences(PreferenceManager
.getDefaultSharedPreferences(requireContext())); .getDefaultSharedPreferences(requireContext()));
finishImport(file); finishImport();
}); });
alert.show(); alert.show();
} else { } else {
finishImport(file); finishImport();
} }
} catch (final Exception e) { } catch (final Exception e) {
ErrorActivity.reportUiErrorInSnackbar(this, "Importing database", e); ErrorActivity.reportUiErrorInSnackbar(this, "Importing database", e);
@ -264,39 +254,29 @@ public class ContentSettingsFragment extends BasePreferenceFragment {
/** /**
* Save import path and restart system. * Save import path and restart system.
*
* @param file The file of the created backup
*/ */
private void finishImport(@NonNull final File file) { private void finishImport() {
if (file.getParentFile() != null) { // save import path only on success; save immediately because app is about to exit
//immediately because app is about to exit saveLastImportExportDataUri(true);
setImportExportDataPath(file.getParentFile(), true);
}
// restart app to properly load db // restart app to properly load db
System.exit(0); NavigationHelper.restartApp(requireActivity());
} }
@SuppressLint("ApplySharedPref") private Uri getImportExportDataUri() {
private void setImportExportDataPath(@NonNull final File file, final boolean immediately) { final String path = defaultPreferences.getString(importExportDataPathKey, null);
final String directoryPath; return isBlank(path) ? null : Uri.parse(path);
if (file.isDirectory()) {
directoryPath = file.getAbsolutePath();
} else {
final File parentFile = file.getParentFile();
if (parentFile != null) {
directoryPath = parentFile.getAbsolutePath();
} else {
directoryPath = "";
} }
}
final SharedPreferences.Editor editor = defaultPreferences private void saveLastImportExportDataUri(final boolean immediately) {
.edit() if (lastImportExportDataUri != null) {
.putString(importExportDataPathKey, directoryPath); final SharedPreferences.Editor editor = defaultPreferences.edit()
.putString(importExportDataPathKey, lastImportExportDataUri.toString());
if (immediately) { if (immediately) {
editor.commit(); // noinspection ApplySharedPref
editor.commit(); // app about to be restarted, commit immediately
} else { } else {
editor.apply(); editor.apply();
} }
} }
}
} }

View file

@ -1,6 +1,8 @@
package org.schabi.newpipe.settings package org.schabi.newpipe.settings
import android.content.SharedPreferences 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 org.schabi.newpipe.util.ZipHelper
import java.io.BufferedOutputStream import java.io.BufferedOutputStream
import java.io.FileInputStream import java.io.FileInputStream
@ -17,8 +19,9 @@ class ContentSettingsManager(private val fileLocator: NewPipeFileLocator) {
* It also creates the file. * It also creates the file.
*/ */
@Throws(Exception::class) @Throws(Exception::class)
fun exportDatabase(preferences: SharedPreferences, outputPath: String) { fun exportDatabase(preferences: SharedPreferences, file: StoredFileHelper) {
ZipOutputStream(BufferedOutputStream(FileOutputStream(outputPath))) file.create()
ZipOutputStream(BufferedOutputStream(SharpOutputStream(file.stream)))
.use { outZip -> .use { outZip ->
ZipHelper.addFileToZip(outZip, fileLocator.db.path, "newpipe.db") 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() return fileLocator.dbDir.exists() || fileLocator.dbDir.mkdir()
} }
fun extractDb(filePath: String): Boolean { fun extractDb(file: StoredFileHelper): Boolean {
val success = ZipHelper.extractFileFromZip(filePath, fileLocator.db.path, "newpipe.db") val success = ZipHelper.extractFileFromZip(file, fileLocator.db.path, "newpipe.db")
if (success) { if (success) {
fileLocator.dbJournal.delete() fileLocator.dbJournal.delete()
fileLocator.dbWal.delete() fileLocator.dbWal.delete()
@ -59,9 +62,8 @@ class ContentSettingsManager(private val fileLocator: NewPipeFileLocator) {
return success return success
} }
fun extractSettings(filePath: String): Boolean { fun extractSettings(file: StoredFileHelper): Boolean {
return ZipHelper return ZipHelper.extractFileFromZip(file, fileLocator.settings.path, "newpipe.settings")
.extractFileFromZip(filePath, fileLocator.settings.path, "newpipe.settings")
} }
fun loadSharedPreferences(preferences: SharedPreferences) { fun loadSharedPreferences(preferences: SharedPreferences) {

View file

@ -8,15 +8,20 @@ import android.net.Uri;
import android.os.Build; import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.util.Log; import android.util.Log;
import android.widget.Toast;
import androidx.activity.result.ActivityResult;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult;
import androidx.annotation.NonNull;
import androidx.annotation.StringRes; import androidx.annotation.StringRes;
import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AlertDialog;
import androidx.preference.Preference; import androidx.preference.Preference;
import androidx.preference.SwitchPreferenceCompat;
import com.nononsenseapps.filepicker.Utils; import com.nononsenseapps.filepicker.Utils;
import org.schabi.newpipe.R; import org.schabi.newpipe.R;
import org.schabi.newpipe.streams.io.StoredDirectoryHelper;
import org.schabi.newpipe.util.FilePickerActivityHelper; import org.schabi.newpipe.util.FilePickerActivityHelper;
import java.io.File; import java.io.File;
@ -26,14 +31,10 @@ import java.net.URI;
import java.net.URLDecoder; import java.net.URLDecoder;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import us.shandian.giga.io.StoredDirectoryHelper;
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
public class DownloadSettingsFragment extends BasePreferenceFragment { public class DownloadSettingsFragment extends BasePreferenceFragment {
public static final boolean IGNORE_RELEASE_ON_OLD_PATH = true; public static final boolean IGNORE_RELEASE_ON_OLD_PATH = true;
private static final int REQUEST_DOWNLOAD_VIDEO_PATH = 0x1235;
private static final int REQUEST_DOWNLOAD_AUDIO_PATH = 0x1236;
private String downloadPathVideoPreference; private String downloadPathVideoPreference;
private String downloadPathAudioPreference; private String downloadPathAudioPreference;
private String storageUseSafPreference; private String storageUseSafPreference;
@ -43,6 +44,12 @@ public class DownloadSettingsFragment extends BasePreferenceFragment {
private Preference prefStorageAsk; private Preference prefStorageAsk;
private Context ctx; private Context ctx;
private final ActivityResultLauncher<Intent> requestDownloadVideoPathLauncher =
registerForActivityResult(
new StartActivityForResult(), this::requestDownloadVideoPathResult);
private final ActivityResultLauncher<Intent> requestDownloadAudioPathLauncher =
registerForActivityResult(
new StartActivityForResult(), this::requestDownloadAudioPathResult);
@Override @Override
public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) {
@ -57,13 +64,23 @@ public class DownloadSettingsFragment extends BasePreferenceFragment {
prefPathAudio = findPreference(downloadPathAudioPreference); prefPathAudio = findPreference(downloadPathAudioPreference);
prefStorageAsk = findPreference(downloadStorageAsk); 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(); updatePreferencesSummary();
updatePathPickers(!defaultPreferences.getBoolean(downloadStorageAsk, false)); 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) if (hasInvalidPath(downloadPathVideoPreference)
|| hasInvalidPath(downloadPathAudioPreference)) { || hasInvalidPath(downloadPathAudioPreference)) {
updatePreferencesSummary(); updatePreferencesSummary();
@ -76,7 +93,7 @@ public class DownloadSettingsFragment extends BasePreferenceFragment {
} }
@Override @Override
public void onAttach(final Context context) { public void onAttach(@NonNull final Context context) {
super.onAttach(context); super.onAttach(context);
ctx = context; ctx = context;
} }
@ -174,65 +191,51 @@ public class DownloadSettingsFragment extends BasePreferenceFragment {
} }
final String key = preference.getKey(); final String key = preference.getKey();
final int request;
if (key.equals(storageUseSafPreference)) { if (key.equals(storageUseSafPreference)) {
Toast.makeText(getContext(), R.string.download_choose_new_path, if (!NewPipeSettings.useStorageAccessFramework(ctx)) {
Toast.LENGTH_LONG).show(); NewPipeSettings.saveDefaultVideoDownloadDirectory(ctx);
NewPipeSettings.saveDefaultAudioDownloadDirectory(ctx);
} else {
defaultPreferences.edit().putString(downloadPathVideoPreference, null)
.putString(downloadPathAudioPreference, null).apply();
}
updatePreferencesSummary();
return true; return true;
} else if (key.equals(downloadPathVideoPreference)) { } else if (key.equals(downloadPathVideoPreference)) {
request = REQUEST_DOWNLOAD_VIDEO_PATH; launchDirectoryPicker(requestDownloadVideoPathLauncher);
} else if (key.equals(downloadPathAudioPreference)) { } else if (key.equals(downloadPathAudioPreference)) {
request = REQUEST_DOWNLOAD_AUDIO_PATH; launchDirectoryPicker(requestDownloadAudioPathLauncher);
} else { } else {
return super.onPreferenceTreeClick(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);
return true; return true;
} }
@Override private void launchDirectoryPicker(final ActivityResultLauncher<Intent> launcher) {
public void onActivityResult(final int requestCode, final int resultCode, final Intent data) { launcher.launch(StoredDirectoryHelper.getPicker(ctx));
}
private void requestDownloadVideoPathResult(final ActivityResult result) {
requestDownloadPathResult(result, downloadPathVideoPreference);
}
private void requestDownloadAudioPathResult(final ActivityResult result) {
requestDownloadPathResult(result, downloadPathAudioPreference);
}
private void requestDownloadPathResult(final ActivityResult result, final String key) {
assureCorrectAppLanguage(getContext()); assureCorrectAppLanguage(getContext());
super.onActivityResult(requestCode, resultCode, data);
if (DEBUG) {
Log.d(TAG, "onActivityResult() called with: "
+ "requestCode = [" + requestCode + "], "
+ "resultCode = [" + resultCode + "], data = [" + data + "]"
);
}
if (resultCode != Activity.RESULT_OK) { if (result.getResultCode() != Activity.RESULT_OK) {
return; return;
} }
final String key; Uri uri = null;
if (requestCode == REQUEST_DOWNLOAD_VIDEO_PATH) { if (result.getData() != null) {
key = downloadPathVideoPreference; uri = result.getData().getData();
} else if (requestCode == REQUEST_DOWNLOAD_AUDIO_PATH) {
key = downloadPathAudioPreference;
} else {
return;
} }
Uri uri = data.getData();
if (uri == null) { if (uri == null) {
showMessageDialog(R.string.general_error, R.string.invalid_directory); showMessageDialog(R.string.general_error, R.string.invalid_directory);
return; return;

View file

@ -2,16 +2,20 @@ package org.schabi.newpipe.settings;
import android.content.Context; import android.content.Context;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import android.os.Build;
import android.os.Environment; import android.os.Environment;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.preference.PreferenceManager; import androidx.preference.PreferenceManager;
import org.schabi.newpipe.R; import org.schabi.newpipe.R;
import org.schabi.newpipe.util.DeviceUtils;
import java.io.File; import java.io.File;
import java.util.Set; import java.util.Set;
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
/* /*
* Created by k3b on 07.01.2016. * Created by k3b on 07.01.2016.
* *
@ -66,26 +70,29 @@ public final class NewPipeSettings {
PreferenceManager.setDefaultValues(context, R.xml.debug_settings, true); PreferenceManager.setDefaultValues(context, R.xml.debug_settings, true);
PreferenceManager.setDefaultValues(context, R.xml.sponsor_block_category_settings, true); PreferenceManager.setDefaultValues(context, R.xml.sponsor_block_category_settings, true);
getVideoDownloadFolder(context); saveDefaultVideoDownloadDirectory(context);
getAudioDownloadFolder(context); saveDefaultAudioDownloadDirectory(context);
SettingMigrations.initMigrations(context, isFirstRun); SettingMigrations.initMigrations(context, isFirstRun);
} }
private static void getVideoDownloadFolder(final Context context) { static void saveDefaultVideoDownloadDirectory(final Context context) {
getDir(context, R.string.download_path_video_key, Environment.DIRECTORY_MOVIES); saveDefaultDirectory(context, R.string.download_path_video_key,
Environment.DIRECTORY_MOVIES);
} }
private static void getAudioDownloadFolder(final Context context) { static void saveDefaultAudioDownloadDirectory(final Context context) {
getDir(context, R.string.download_path_audio_key, Environment.DIRECTORY_MUSIC); saveDefaultDirectory(context, R.string.download_path_audio_key,
Environment.DIRECTORY_MUSIC);
} }
private static void getDir(final Context context, final int keyID, private static void saveDefaultDirectory(final Context context, final int keyID,
final String defaultDirectoryName) { final String defaultDirectoryName) {
if (!useStorageAccessFramework(context)) {
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
final String key = context.getString(keyID); final String key = context.getString(keyID);
final String downloadPath = prefs.getString(key, null); final String downloadPath = prefs.getString(key, null);
if ((downloadPath != null) && (!downloadPath.isEmpty())) { if (!isNullOrEmpty(downloadPath)) {
return; return;
} }
@ -93,6 +100,7 @@ public final class NewPipeSettings {
spEditor.putString(key, getNewPipeChildFolderPathForDir(getDir(defaultDirectoryName))); spEditor.putString(key, getNewPipeChildFolderPathForDir(getDir(defaultDirectoryName)));
spEditor.apply(); spEditor.apply();
} }
}
@NonNull @NonNull
public static File getDir(final String defaultDirectoryName) { public static File getDir(final String defaultDirectoryName) {
@ -104,10 +112,17 @@ public final class NewPipeSettings {
} }
public static boolean useStorageAccessFramework(final Context context) { 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 String key = context.getString(R.string.storage_use_saf);
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
return prefs.getBoolean(key, false); return prefs.getBoolean(key, true);
} }
} }

View file

@ -139,7 +139,8 @@ public class PeertubeInstanceListFragment extends Fragment {
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
@Override @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); super.onCreateOptionsMenu(menu, inflater);
final MenuItem restoreItem = menu final MenuItem restoreItem = menu
@ -279,7 +280,7 @@ public class PeertubeInstanceListFragment extends Fragment {
return new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN, return new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN,
ItemTouchHelper.START | ItemTouchHelper.END) { ItemTouchHelper.START | ItemTouchHelper.END) {
@Override @Override
public int interpolateOutOfBoundsScroll(final RecyclerView recyclerView, public int interpolateOutOfBoundsScroll(@NonNull final RecyclerView recyclerView,
final int viewSize, final int viewSize,
final int viewSizeOutOfBounds, final int viewSizeOutOfBounds,
final int totalSize, final int totalSize,
@ -292,9 +293,9 @@ public class PeertubeInstanceListFragment extends Fragment {
} }
@Override @Override
public boolean onMove(final RecyclerView recyclerView, public boolean onMove(@NonNull final RecyclerView recyclerView,
final RecyclerView.ViewHolder source, @NonNull final RecyclerView.ViewHolder source,
final RecyclerView.ViewHolder target) { @NonNull final RecyclerView.ViewHolder target) {
if (source.getItemViewType() != target.getItemViewType() if (source.getItemViewType() != target.getItemViewType()
|| instanceListAdapter == null) { || instanceListAdapter == null) {
return false; return false;
@ -317,7 +318,8 @@ public class PeertubeInstanceListFragment extends Fragment {
} }
@Override @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(); final int position = viewHolder.getAdapterPosition();
// do not allow swiping the selected instance // do not allow swiping the selected instance
if (instanceList.get(position).getUrl().equals(selectedInstance.getUrl())) { if (instanceList.get(position).getUrl().equals(selectedInstance.getUrl())) {

View file

@ -8,6 +8,7 @@ import android.view.ViewGroup;
import android.widget.ImageView; import android.widget.ImageView;
import android.widget.TextView; import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.appcompat.content.res.AppCompatResources; import androidx.appcompat.content.res.AppCompatResources;
import androidx.fragment.app.DialogFragment; import androidx.fragment.app.DialogFragment;
@ -92,7 +93,7 @@ public class SelectKioskFragment extends DialogFragment {
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
@Override @Override
public void onCancel(final DialogInterface dialogInterface) { public void onCancel(@NonNull final DialogInterface dialogInterface) {
super.onCancel(dialogInterface); super.onCancel(dialogInterface);
if (onCancelListener != null) { if (onCancelListener != null) {
onCancelListener.onCancel(); onCancelListener.onCancel();
@ -138,6 +139,7 @@ public class SelectKioskFragment extends DialogFragment {
return kioskList.size(); return kioskList.size();
} }
@NonNull
public SelectKioskItemHolder onCreateViewHolder(final ViewGroup parent, final int type) { public SelectKioskItemHolder onCreateViewHolder(final ViewGroup parent, final int type) {
final View item = LayoutInflater.from(parent.getContext()) final View item = LayoutInflater.from(parent.getContext())
.inflate(R.layout.select_kiosk_item, parent, false); .inflate(R.layout.select_kiosk_item, parent, false);

View file

@ -2,6 +2,7 @@ package org.schabi.newpipe.settings;
import android.content.Context; import android.content.Context;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import android.os.Build;
import android.util.Log; import android.util.Log;
import androidx.preference.PreferenceManager; import androidx.preference.PreferenceManager;
@ -10,6 +11,7 @@ import org.schabi.newpipe.R;
import org.schabi.newpipe.error.ErrorActivity; import org.schabi.newpipe.error.ErrorActivity;
import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.util.DeviceUtils;
import static org.schabi.newpipe.MainActivity.DEBUG; 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. * 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; private static SharedPreferences sp;
public static final Migration MIGRATION_0_1 = new Migration(0, 1) { public static final Migration MIGRATION_0_1 = new Migration(0, 1) {
@ -54,6 +56,22 @@ public final class SettingMigrations {
} }
}; };
public static final Migration MIGRATION_2_3 = new Migration(2, 3) {
@Override
protected void migrate(final Context context) {
// Storage Access Framework implementation was improved in #5415, allowing the modern
// and standard way to access folders and files to be used consistently everywhere.
// We reset the setting to its default value, i.e. "use SAF", since now there are no
// more issues with SAF and users should use that one instead of the old
// NoNonsenseFilePicker. SAF does not work on KitKat and below, though, so the setting
// is set to false in that case. 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. * List of all implemented migrations.
* <p> * <p>
@ -62,7 +80,8 @@ public final class SettingMigrations {
*/ */
private static final Migration[] SETTING_MIGRATIONS = { private static final Migration[] SETTING_MIGRATIONS = {
MIGRATION_0_1, MIGRATION_0_1,
MIGRATION_1_2 MIGRATION_1_2,
MIGRATION_2_3
}; };

View file

@ -1,6 +1,5 @@
package org.schabi.newpipe.settings; package org.schabi.newpipe.settings;
import android.content.Context;
import android.os.Bundle; import android.os.Bundle;
import android.view.Menu; import android.view.Menu;
import android.view.MenuItem; import android.view.MenuItem;
@ -41,11 +40,6 @@ import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
public class SettingsActivity extends AppCompatActivity public class SettingsActivity extends AppCompatActivity
implements BasePreferenceFragment.OnPreferenceStartFragmentCallback { implements BasePreferenceFragment.OnPreferenceStartFragmentCallback {
public static void initSettings(final Context context) {
NewPipeSettings.initSettings(context);
}
@Override @Override
protected void onCreate(final Bundle savedInstanceBundle) { protected void onCreate(final Bundle savedInstanceBundle) {
setTheme(ThemeHelper.getSettingsThemeStyle(this)); setTheme(ThemeHelper.getSettingsThemeStyle(this));

View file

@ -106,7 +106,8 @@ public class ChooseTabsFragment extends Fragment {
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
@Override @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); super.onCreateOptionsMenu(menu, inflater);
final MenuItem restoreItem = menu.add(Menu.NONE, MENU_ITEM_RESTORE_ID, Menu.NONE, final MenuItem restoreItem = menu.add(Menu.NONE, MENU_ITEM_RESTORE_ID, Menu.NONE,
@ -192,13 +193,13 @@ public class ChooseTabsFragment extends Fragment {
final SelectKioskFragment selectKioskFragment = new SelectKioskFragment(); final SelectKioskFragment selectKioskFragment = new SelectKioskFragment();
selectKioskFragment.setOnSelectedListener((serviceId, kioskId, kioskName) -> selectKioskFragment.setOnSelectedListener((serviceId, kioskId, kioskName) ->
addTab(new Tab.KioskTab(serviceId, kioskId))); addTab(new Tab.KioskTab(serviceId, kioskId)));
selectKioskFragment.show(requireFragmentManager(), "select_kiosk"); selectKioskFragment.show(getParentFragmentManager(), "select_kiosk");
return; return;
case CHANNEL: case CHANNEL:
final SelectChannelFragment selectChannelFragment = new SelectChannelFragment(); final SelectChannelFragment selectChannelFragment = new SelectChannelFragment();
selectChannelFragment.setOnSelectedListener((serviceId, url, name) -> selectChannelFragment.setOnSelectedListener((serviceId, url, name) ->
addTab(new Tab.ChannelTab(serviceId, url, name))); addTab(new Tab.ChannelTab(serviceId, url, name)));
selectChannelFragment.show(requireFragmentManager(), "select_channel"); selectChannelFragment.show(getParentFragmentManager(), "select_channel");
return; return;
case PLAYLIST: case PLAYLIST:
final SelectPlaylistFragment selectPlaylistFragment = new SelectPlaylistFragment(); final SelectPlaylistFragment selectPlaylistFragment = new SelectPlaylistFragment();
@ -215,7 +216,7 @@ public class ChooseTabsFragment extends Fragment {
addTab(new Tab.PlaylistTab(serviceId, url, name)); addTab(new Tab.PlaylistTab(serviceId, url, name));
} }
}); });
selectPlaylistFragment.show(requireFragmentManager(), "select_playlist"); selectPlaylistFragment.show(getParentFragmentManager(), "select_playlist");
return; return;
default: default:
addTab(type.getTab()); addTab(type.getTab());
@ -277,7 +278,7 @@ public class ChooseTabsFragment extends Fragment {
return new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN, return new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN,
ItemTouchHelper.START | ItemTouchHelper.END) { ItemTouchHelper.START | ItemTouchHelper.END) {
@Override @Override
public int interpolateOutOfBoundsScroll(final RecyclerView recyclerView, public int interpolateOutOfBoundsScroll(@NonNull final RecyclerView recyclerView,
final int viewSize, final int viewSize,
final int viewSizeOutOfBounds, final int viewSizeOutOfBounds,
final int totalSize, final int totalSize,
@ -290,9 +291,9 @@ public class ChooseTabsFragment extends Fragment {
} }
@Override @Override
public boolean onMove(final RecyclerView recyclerView, public boolean onMove(@NonNull final RecyclerView recyclerView,
final RecyclerView.ViewHolder source, @NonNull final RecyclerView.ViewHolder source,
final RecyclerView.ViewHolder target) { @NonNull final RecyclerView.ViewHolder target) {
if (source.getItemViewType() != target.getItemViewType() if (source.getItemViewType() != target.getItemViewType()
|| selectedTabsAdapter == null) { || selectedTabsAdapter == null) {
return false; return false;
@ -315,7 +316,8 @@ public class ChooseTabsFragment extends Fragment {
} }
@Override @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(); final int position = viewHolder.getAdapterPosition();
tabList.remove(position); tabList.remove(position);
selectedTabsAdapter.notifyItemRemoved(position); selectedTabsAdapter.notifyItemRemoved(position);

View file

@ -112,12 +112,16 @@ public abstract class Tab {
@Override @Override
public boolean equals(final Object obj) { public boolean equals(final Object obj) {
if (obj == this) { if (!(obj instanceof Tab)) {
return true; return false;
}
final Tab other = (Tab) obj;
return getTabId() == other.getTabId();
} }
return obj instanceof Tab && obj.getClass() == this.getClass() @Override
&& ((Tab) obj).getTabId() == this.getTabId(); public int hashCode() {
return Objects.hashCode(getTabId());
} }
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////
@ -358,8 +362,18 @@ public abstract class Tab {
@Override @Override
public boolean equals(final Object obj) { public boolean equals(final Object obj) {
return super.equals(obj) && kioskServiceId == ((KioskTab) obj).kioskServiceId if (!(obj instanceof KioskTab)) {
&& Objects.equals(kioskId, ((KioskTab) obj).kioskId); 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() { public int getKioskServiceId() {
@ -432,9 +446,19 @@ public abstract class Tab {
@Override @Override
public boolean equals(final Object obj) { public boolean equals(final Object obj) {
return super.equals(obj) && channelServiceId == ((ChannelTab) obj).channelServiceId if (!(obj instanceof ChannelTab)) {
&& Objects.equals(channelUrl, ((ChannelTab) obj).channelUrl) return false;
&& Objects.equals(channelName, ((ChannelTab) obj).channelName); }
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() { public int getChannelServiceId() {
@ -576,15 +600,30 @@ public abstract class Tab {
@Override @Override
public boolean equals(final Object obj) { public boolean equals(final Object obj) {
if (!(super.equals(obj) if (!(obj instanceof PlaylistTab)) {
&& Objects.equals(playlistType, ((PlaylistTab) obj).playlistType) return false;
&& Objects.equals(playlistName, ((PlaylistTab) obj).playlistName))) {
return false; // base objects are different
} }
return (playlistId == ((PlaylistTab) obj).playlistId) // local final PlaylistTab other = (PlaylistTab) obj;
|| (playlistServiceId == ((PlaylistTab) obj).playlistServiceId // remote
&& Objects.equals(playlistUrl, ((PlaylistTab) obj).playlistUrl)); 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() { public int getPlaylistServiceId() {

View file

@ -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();
}
}

View file

@ -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();
}
}

View file

@ -1,12 +1,20 @@
package org.schabi.newpipe.streams.io; package org.schabi.newpipe.streams.io;
import java.io.Closeable; import java.io.Closeable;
import java.io.Flushable;
import java.io.IOException; 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() throws IOException;
public abstract int read(byte[] buffer) throws IOException; public abstract int read(byte[] buffer) throws IOException;

View file

@ -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.ContentResolver;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
@ -13,6 +12,9 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.documentfile.provider.DocumentFile; import androidx.documentfile.provider.DocumentFile;
import org.schabi.newpipe.settings.NewPipeSettings;
import org.schabi.newpipe.util.FilePickerActivityHelper;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.net.URI; import java.net.URI;
@ -21,10 +23,11 @@ import java.util.Collections;
import static android.provider.DocumentsContract.Document.COLUMN_DISPLAY_NAME; import static android.provider.DocumentsContract.Document.COLUMN_DISPLAY_NAME;
import static android.provider.DocumentsContract.Root.COLUMN_DOCUMENT_ID; import static android.provider.DocumentsContract.Root.COLUMN_DOCUMENT_ID;
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
public class StoredDirectoryHelper { 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 File ioTree;
private DocumentFile docTree; private DocumentFile docTree;
@ -33,7 +36,8 @@ public class StoredDirectoryHelper {
private final String tag; 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; this.tag = tag;
if (ContentResolver.SCHEME_FILE.equalsIgnoreCase(path.getScheme())) { if (ContentResolver.SCHEME_FILE.equalsIgnoreCase(path.getScheme())) {
@ -45,61 +49,59 @@ public class StoredDirectoryHelper {
try { try {
this.context.getContentResolver().takePersistableUriPermission(path, PERMISSION_FLAGS); this.context.getContentResolver().takePersistableUriPermission(path, PERMISSION_FLAGS);
} catch (Exception e) { } catch (final Exception e) {
throw new IOException(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"); throw new IOException("Storage Access Framework with Directory API is not available");
}
this.docTree = DocumentFile.fromTreeUri(context, path); this.docTree = DocumentFile.fromTreeUri(context, path);
if (this.docTree == null) if (this.docTree == null) {
throw new IOException("Failed to create the tree from Uri"); 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); return createFile(filename, mime, false);
} }
public StoredFileHelper createUniqueFile(String name, String mime) { public StoredFileHelper createUniqueFile(final String name, final String mime) {
ArrayList<String> matches = new ArrayList<>(); final ArrayList<String> matches = new ArrayList<>();
String[] filename = splitFilename(name); final String[] filename = splitFilename(name);
String lcFilename = filename[0].toLowerCase(); final String lcFilename = filename[0].toLowerCase();
if (docTree == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { 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()); addIfStartWith(matches, lcFilename, file.getName());
}
} else { } else {
// warning: SAF file listing is very slow // warning: SAF file listing is very slow
Uri docTreeChildren = DocumentsContract.buildChildDocumentsUriUsingTree( final Uri docTreeChildren = DocumentsContract.buildChildDocumentsUriUsingTree(
docTree.getUri(), DocumentsContract.getDocumentId(docTree.getUri()) docTree.getUri(), DocumentsContract.getDocumentId(docTree.getUri()));
);
String[] projection = {COLUMN_DISPLAY_NAME}; final String[] projection = new String[]{COLUMN_DISPLAY_NAME};
String selection = "(LOWER(" + COLUMN_DISPLAY_NAME + ") LIKE ?%"; final String selection = "(LOWER(" + COLUMN_DISPLAY_NAME + ") LIKE ?%";
ContentResolver cr = context.getContentResolver(); 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) { if (cursor != null) {
while (cursor.moveToNext()) while (cursor.moveToNext()) {
addIfStartWith(matches, lcFilename, cursor.getString(0)); addIfStartWith(matches, lcFilename, cursor.getString(0));
} }
} }
} }
}
if (matches.size() < 1) { if (matches.size() < 1) {
return createFile(name, mime, true); return createFile(name, mime, true);
} else { } else {
// check if the filename is in use // check if the filename is in use
String lcName = name.toLowerCase(); String lcName = name.toLowerCase();
for (String testName : matches) { for (final String testName : matches) {
if (testName.equals(lcName)) { if (testName.equals(lcName)) {
lcName = null; lcName = null;
break; break;
@ -107,28 +109,34 @@ public class StoredDirectoryHelper {
} }
// check if not in use // 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); Collections.sort(matches, String::compareTo);
for (int i = 1; i < 1000; i++) { 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(makeFileName(filename[0], i, filename[1]), mime, true);
} }
return createFile(String.valueOf(System.currentTimeMillis()).concat(filename[1]), mime, false);
} }
private StoredFileHelper createFile(String filename, String mime, boolean safe) { return createFile(String.valueOf(System.currentTimeMillis()).concat(filename[1]), mime,
StoredFileHelper storage; false);
}
private StoredFileHelper createFile(final String filename, final String mime,
final boolean safe) {
final StoredFileHelper storage;
try { try {
if (docTree == null) if (docTree == null) {
storage = new StoredFileHelper(ioTree, filename, mime); storage = new StoredFileHelper(ioTree, filename, mime);
else } else {
storage = new StoredFileHelper(context, docTree, filename, mime, safe); storage = new StoredFileHelper(context, docTree, filename, mime, safe);
} catch (IOException e) { }
} catch (final IOException e) {
return null; return null;
} }
@ -146,7 +154,7 @@ public class StoredDirectoryHelper {
} }
/** /**
* Indicates whatever if is possible access using the {@code java.io} API * Indicates whether it's using the {@code java.io} API.
* *
* @return {@code true} for Java I/O API, otherwise, {@code false} for Storage Access Framework * @return {@code true} for Java I/O API, otherwise, {@code false} for Storage Access Framework
*/ */
@ -169,7 +177,9 @@ public class StoredDirectoryHelper {
return ioTree.exists() || ioTree.mkdirs(); return ioTree.exists() || ioTree.mkdirs();
} }
if (docTree.exists()) return true; if (docTree.exists()) {
return true;
}
try { try {
DocumentFile parent; DocumentFile parent;
@ -177,14 +187,18 @@ public class StoredDirectoryHelper {
while (true) { while (true) {
parent = docTree.getParentFile(); parent = docTree.getParentFile();
if (parent == null || child == null) break; if (parent == null || child == null) {
if (parent.exists()) return true; break;
}
if (parent.exists()) {
return true;
}
parent.createDirectory(child); 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 // no more parent directories or unsupported by the storage provider
} }
@ -195,13 +209,13 @@ public class StoredDirectoryHelper {
return tag; return tag;
} }
public Uri findFile(String filename) { public Uri findFile(final String filename) {
if (docTree == null) { if (docTree == null) {
File res = new File(ioTree, filename); final File res = new File(ioTree, filename);
return res.exists() ? Uri.fromFile(res) : null; 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(); return res == null ? null : res.getUri();
} }
@ -209,82 +223,115 @@ public class StoredDirectoryHelper {
return docTree == null ? ioTree.canWrite() : docTree.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 @NonNull
@Override @Override
public String toString() { public String toString() {
return (docTree == null ? Uri.fromFile(ioTree) : docTree.getUri()).toString(); return (docTree == null ? Uri.fromFile(ioTree) : docTree.getUri()).toString();
} }
//////////////////// ////////////////////
// Utils // Utils
/////////////////// ///////////////////
private static void addIfStartWith(ArrayList<String> list, @NonNull String base, String str) { private static void addIfStartWith(final ArrayList<String> list, @NonNull final String base,
if (str == null || str.isEmpty()) return; final String str) {
str = str.toLowerCase(); if (isNullOrEmpty(str)) {
if (str.startsWith(base)) list.add(str); return;
}
final String lowerStr = str.toLowerCase();
if (lowerStr.startsWith(base)) {
list.add(lowerStr);
}
} }
private static String[] splitFilename(@NonNull String filename) { private static String[] splitFilename(@NonNull final String filename) {
int dotIndex = filename.lastIndexOf('.'); 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, ""};
}
return new String[]{filename.substring(0, dotIndex), filename.substring(dotIndex)}; 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); 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 context The context
* @param tree Directory where search * @param tree Directory where search
* @param filename Target filename * @param filename Target filename
* @return A {@link DocumentFile} contain the reference, otherwise, null * @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) { 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 name = 0;
final int documentId = 1; final int documentId = 1;
// LOWER() SQL function is not supported // LOWER() SQL function is not supported
String selection = COLUMN_DISPLAY_NAME + " = ?"; final String selection = COLUMN_DISPLAY_NAME + " = ?";
//String selection = COLUMN_DISPLAY_NAME + " LIKE ?%"; //final String selection = COLUMN_DISPLAY_NAME + " LIKE ?%";
Uri childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree( final Uri childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(tree.getUri(),
tree.getUri(), DocumentsContract.getDocumentId(tree.getUri()) DocumentsContract.getDocumentId(tree.getUri()));
); final String[] projection = {COLUMN_DISPLAY_NAME, COLUMN_DOCUMENT_ID};
String[] projection = {COLUMN_DISPLAY_NAME, COLUMN_DOCUMENT_ID}; final ContentResolver contentResolver = context.getContentResolver();
ContentResolver contentResolver = context.getContentResolver();
filename = filename.toLowerCase(); final String lowerFilename = filename.toLowerCase();
try (Cursor cursor = contentResolver.query(childrenUri, projection, selection, new String[]{filename}, null)) { try (Cursor cursor = contentResolver.query(childrenUri, projection, selection,
if (cursor == null) return null; new String[]{lowerFilename}, null)) {
if (cursor == null) {
return null;
}
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
if (cursor.isNull(name) || !cursor.getString(name).toLowerCase().startsWith(filename)) if (cursor.isNull(name)
|| !cursor.getString(name).toLowerCase().startsWith(lowerFilename)) {
continue; continue;
}
return DocumentFile.fromSingleUri( return DocumentFile.fromSingleUri(context,
context, DocumentsContract.buildDocumentUriUsingTree( DocumentsContract.buildDocumentUriUsingTree(tree.getUri(),
tree.getUri(), cursor.getString(documentId) cursor.getString(documentId)));
)
);
} }
} }
return null; 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);
}
}
} }

View file

@ -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());
}
}
}

View file

@ -1,6 +1,5 @@
package org.schabi.newpipe.util; package org.schabi.newpipe.util;
import android.content.Context;
import android.text.Layout; import android.text.Layout;
import android.text.Selection; import android.text.Selection;
import android.text.Spannable; import android.text.Spannable;
@ -11,27 +10,14 @@ import android.view.MotionEvent;
import android.view.View; import android.view.View;
import android.widget.TextView; import android.widget.TextView;
import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.util.external_communication.ShareUtils;
import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.util.external_communication.InternalUrlsHandler;
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 java.util.regex.Matcher; import io.reactivex.rxjava3.disposables.CompositeDisposable;
import java.util.regex.Pattern;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.schedulers.Schedulers;
public class CommentTextOnTouchListener implements View.OnTouchListener { public class CommentTextOnTouchListener implements View.OnTouchListener {
public static final CommentTextOnTouchListener INSTANCE = new CommentTextOnTouchListener(); public static final CommentTextOnTouchListener INSTANCE = new CommentTextOnTouchListener();
private static final Pattern TIMESTAMP_PATTERN = Pattern.compile("(.*)#timestamp=(\\d+)");
@Override @Override
public boolean onTouch(final View v, final MotionEvent event) { public boolean onTouch(final View v, final MotionEvent event) {
if (!(v instanceof TextView)) { if (!(v instanceof TextView)) {
@ -64,13 +50,12 @@ public class CommentTextOnTouchListener implements View.OnTouchListener {
if (link.length != 0) { if (link.length != 0) {
if (action == MotionEvent.ACTION_UP) { if (action == MotionEvent.ACTION_UP) {
boolean handled = false;
if (link[0] instanceof URLSpan) { if (link[0] instanceof URLSpan) {
handled = handleUrl(v.getContext(), (URLSpan) link[0]); final String url = ((URLSpan) link[0]).getURL();
if (!InternalUrlsHandler.handleUrlCommentsTimestamp(
new CompositeDisposable(), v.getContext(), url)) {
ShareUtils.openUrlInBrowser(v.getContext(), url, false);
} }
if (!handled) {
ShareUtils.openUrlInBrowser(v.getContext(),
((URLSpan) link[0]).getURL(), false);
} }
} else if (action == MotionEvent.ACTION_DOWN) { } else if (action == MotionEvent.ACTION_DOWN) {
Selection.setSelection(buffer, Selection.setSelection(buffer,
@ -83,52 +68,4 @@ public class CommentTextOnTouchListener implements View.OnTouchListener {
} }
return false; 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;
}
} }

View file

@ -12,13 +12,16 @@ import android.view.KeyEvent;
import androidx.annotation.Dimension; import androidx.annotation.Dimension;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.core.content.ContextCompat; import androidx.core.content.ContextCompat;
import androidx.preference.PreferenceManager;
import org.schabi.newpipe.App; import org.schabi.newpipe.App;
import org.schabi.newpipe.R;
public final class DeviceUtils { public final class DeviceUtils {
private static final String AMAZON_FEATURE_FIRE_TV = "amazon.hardware.fire_tv"; private static final String AMAZON_FEATURE_FIRE_TV = "amazon.hardware.fire_tv";
private static Boolean isTV = null; private static Boolean isTV = null;
private static Boolean isFireTV = null;
/* /*
* Devices that do not support media tunneling * Devices that do not support media tunneling
@ -33,6 +36,16 @@ public final class DeviceUtils {
private 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) { public static boolean isTv(final Context context) {
if (isTV != null) { if (isTV != null) {
return isTV; return isTV;
@ -43,7 +56,7 @@ public final class DeviceUtils {
// from doc: https://developer.android.com/training/tv/start/hardware.html#runtime-check // from doc: https://developer.android.com/training/tv/start/hardware.html#runtime-check
boolean isTv = ContextCompat.getSystemService(context, UiModeManager.class) boolean isTv = ContextCompat.getSystemService(context, UiModeManager.class)
.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION .getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION
|| pm.hasSystemFeature(AMAZON_FEATURE_FIRE_TV) || isFireTv()
|| pm.hasSystemFeature(PackageManager.FEATURE_TELEVISION); || pm.hasSystemFeature(PackageManager.FEATURE_TELEVISION);
// from https://stackoverflow.com/a/58932366 // from https://stackoverflow.com/a/58932366
@ -65,10 +78,18 @@ public final class DeviceUtils {
} }
public static boolean isTablet(@NonNull final Context context) { public static boolean isTablet(@NonNull final Context context) {
return (context final String tabletModeSetting = PreferenceManager.getDefaultSharedPreferences(context)
.getResources() .getString(context.getString(R.string.tablet_mode_key), "");
.getConfiguration().screenLayout & Configuration.SCREENLAYOUT_SIZE_MASK)
>= Configuration.SCREENLAYOUT_SIZE_LARGE; 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) { public static boolean isConfirmKey(final int keyCode) {

View file

@ -30,6 +30,7 @@ import androidx.preference.PreferenceManager;
import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.MainActivity;
import org.schabi.newpipe.R; import org.schabi.newpipe.R;
import org.schabi.newpipe.util.external_communication.TextLinkifier;
import org.schabi.newpipe.extractor.Info; import org.schabi.newpipe.extractor.Info;
import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.ListExtractor.InfoItemsPage; import org.schabi.newpipe.extractor.ListExtractor.InfoItemsPage;
@ -54,7 +55,7 @@ import java.util.List;
import io.reactivex.rxjava3.core.Maybe; import io.reactivex.rxjava3.core.Maybe;
import io.reactivex.rxjava3.core.Single; 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; import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
@ -268,18 +269,19 @@ public final class ExtractorHelper {
* @param metaInfos a list of meta information, can be null or empty * @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 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 * @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<MetaInfo> metaInfos, public static void showMetaInfoInTextView(@Nullable final List<MetaInfo> metaInfos,
final TextView metaInfoTextView, final TextView metaInfoTextView,
final View metaInfoSeparator) { final View metaInfoSeparator,
final CompositeDisposable disposables) {
final Context context = metaInfoTextView.getContext(); final Context context = metaInfoTextView.getContext();
if (metaInfos == null || metaInfos.isEmpty() if (metaInfos == null || metaInfos.isEmpty()
|| !PreferenceManager.getDefaultSharedPreferences(context).getBoolean( || !PreferenceManager.getDefaultSharedPreferences(context).getBoolean(
context.getString(R.string.show_meta_info_key), true)) { context.getString(R.string.show_meta_info_key), true)) {
metaInfoTextView.setVisibility(View.GONE); metaInfoTextView.setVisibility(View.GONE);
metaInfoSeparator.setVisibility(View.GONE); metaInfoSeparator.setVisibility(View.GONE);
return Disposable.empty();
} else { } else {
final StringBuilder stringBuilder = new StringBuilder(); final StringBuilder stringBuilder = new StringBuilder();
@ -310,8 +312,8 @@ public final class ExtractorHelper {
} }
metaInfoSeparator.setVisibility(View.VISIBLE); metaInfoSeparator.setVisibility(View.VISIBLE);
return TextLinkifier.createLinksFromHtmlBlock(context, stringBuilder.toString(), TextLinkifier.createLinksFromHtmlBlock(metaInfoTextView, stringBuilder.toString(),
metaInfoTextView, HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_HEADING); HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_HEADING, null, disposables);
} }
} }

View file

@ -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();
}
}

View file

@ -1,7 +1,6 @@
package org.schabi.newpipe.util; package org.schabi.newpipe.util;
import android.content.Context; import android.content.Context;
import android.content.Intent;
import android.net.Uri; import android.net.Uri;
import android.os.Bundle; import android.os.Bundle;
import android.os.Environment; import android.os.Environment;
@ -28,25 +27,6 @@ import java.io.File;
public class FilePickerActivityHelper extends com.nononsenseapps.filepicker.FilePickerActivity { public class FilePickerActivityHelper extends com.nononsenseapps.filepicker.FilePickerActivity {
private CustomFilePickerFragment currentFragment; 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) { public static boolean isOwnFileUri(@NonNull final Context context, @NonNull final Uri uri) {
if (uri.getAuthority() == null) { if (uri.getAuthority() == null) {
return false; return false;

View file

@ -21,6 +21,7 @@ import androidx.fragment.app.FragmentTransaction;
import com.nostra13.universalimageloader.core.ImageLoader; import com.nostra13.universalimageloader.core.ImageLoader;
import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.MainActivity;
import org.schabi.newpipe.NewPipeDatabase;
import org.schabi.newpipe.R; import org.schabi.newpipe.R;
import org.schabi.newpipe.RouterActivity; import org.schabi.newpipe.RouterActivity;
import org.schabi.newpipe.about.AboutActivity; import org.schabi.newpipe.about.AboutActivity;
@ -53,10 +54,11 @@ import org.schabi.newpipe.player.helper.PlayerHolder;
import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.PlayQueueItem; import org.schabi.newpipe.player.playqueue.PlayQueueItem;
import org.schabi.newpipe.settings.SettingsActivity; import org.schabi.newpipe.settings.SettingsActivity;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import java.util.ArrayList; 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 final class NavigationHelper {
public static final String MAIN_FRAGMENT_TAG = "main_fragment_tag"; public static final String MAIN_FRAGMENT_TAG = "main_fragment_tag";
@ -252,7 +254,7 @@ public final class NavigationHelper {
public static void resolveActivityOrAskToInstall(final Context context, final Intent intent) { public static void resolveActivityOrAskToInstall(final Context context, final Intent intent) {
if (intent.resolveActivity(context.getPackageManager()) != null) { if (intent.resolveActivity(context.getPackageManager()) != null) {
ShareUtils.openIntentInApp(context, intent); ShareUtils.openIntentInApp(context, intent, false);
} else { } else {
if (context instanceof Activity) { if (context instanceof Activity) {
new AlertDialog.Builder(context) new AlertDialog.Builder(context)
@ -599,4 +601,17 @@ public final class NavigationHelper {
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(intent); context.startActivity(intent);
} }
/**
* Finish this <code>Activity</code> as well as all <code>Activities</code> running below it
* and then start <code>MainActivity</code>.
*
* @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);
}
} }

View file

@ -18,6 +18,7 @@ import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat; import androidx.core.content.ContextCompat;
import org.schabi.newpipe.R; import org.schabi.newpipe.R;
import org.schabi.newpipe.settings.NewPipeSettings;
public final class PermissionHelper { public final class PermissionHelper {
public static final int DOWNLOAD_DIALOG_REQUEST_CODE = 778; public static final int DOWNLOAD_DIALOG_REQUEST_CODE = 778;
@ -26,6 +27,10 @@ public final class PermissionHelper {
private PermissionHelper() { } private PermissionHelper() { }
public static boolean checkStoragePermissions(final Activity activity, final int requestCode) { 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)) { if (!checkReadStoragePermissions(activity, requestCode)) {
return false; return false;
} }

View file

@ -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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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.
* <p>
* If no browser is set as default, fallbacks to
* {@link ShareUtils#openAppChooser(Context, Intent, String)}
* <p>
* 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.
* <p>
* The intent can be of every type, excepted a web intent for which
* {@link ShareUtils#openUrlInBrowser(Context, String, boolean)} should be used.
* <p>
* 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.
* <p>
* 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.
* <p>
* If no app is set as default, it will return "android" (not on some devices because some
* OEMs changed the app chooser).
* <p>
* 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();
}
}

View file

@ -12,6 +12,8 @@ import org.schabi.newpipe.local.dialog.PlaylistCreationDialog;
import org.schabi.newpipe.player.MainPlayer; import org.schabi.newpipe.player.MainPlayer;
import org.schabi.newpipe.player.helper.PlayerHolder; import org.schabi.newpipe.player.helper.PlayerHolder;
import org.schabi.newpipe.player.playqueue.SinglePlayQueue; 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.Collections;
import java.util.List; import java.util.List;
@ -66,16 +68,14 @@ public enum StreamDialogEntry {
}), // has to be set manually }), // has to be set manually
append_playlist(R.string.append_playlist, (fragment, item) -> { append_playlist(R.string.append_playlist, (fragment, item) -> {
if (fragment.getFragmentManager() != null) {
final PlaylistAppendDialog d = PlaylistAppendDialog final PlaylistAppendDialog d = PlaylistAppendDialog
.fromStreamInfoItems(Collections.singletonList(item)); .fromStreamInfoItems(Collections.singletonList(item));
PlaylistAppendDialog.onPlaylistFound(fragment.getContext(), PlaylistAppendDialog.onPlaylistFound(fragment.getContext(),
() -> d.show(fragment.getFragmentManager(), "StreamDialogEntry@append_playlist"), () -> d.show(fragment.getParentFragmentManager(), "StreamDialogEntry@append_playlist"),
() -> PlaylistCreationDialog.newInstance(d) () -> PlaylistCreationDialog.newInstance(d)
.show(fragment.getFragmentManager(), "StreamDialogEntry@create_playlist") .show(fragment.getParentFragmentManager(), "StreamDialogEntry@create_playlist")
); );
}
}), }),
play_with_kodi(R.string.play_with_kodi_title, (fragment, item) -> { play_with_kodi(R.string.play_with_kodi_title, (fragment, item) -> {
@ -83,12 +83,13 @@ public enum StreamDialogEntry {
try { try {
NavigationHelper.playWithKore(fragment.requireContext(), videoUrl); NavigationHelper.playWithKore(fragment.requireContext(), videoUrl);
} catch (final Exception e) { } catch (final Exception e) {
KoreUtil.showInstallKoreDialog(fragment.getActivity()); KoreUtils.showInstallKoreDialog(fragment.getActivity());
} }
}), }),
share(R.string.share, (fragment, item) -> 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) -> open_in_browser(R.string.open_in_browser, (fragment, item) ->
ShareUtils.openUrlInBrowser(fragment.getContext(), item.getUrl())); ShareUtils.openUrlInBrowser(fragment.getContext(), item.getUrl()));

View file

@ -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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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)}.
* <p>
* 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);
}
}

View file

@ -298,4 +298,43 @@ public final class ThemeHelper {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM); AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM);
} }
} }
/**
* Returns whether the grid layout or the list layout should be used. If the user set "auto"
* mode in settings, decides based on screen orientation (landscape) and size.
*
* @param context the context to use
* @return true:use grid layout, false:use list layout
*/
public static boolean shouldUseGridLayout(final Context context) {
final String listMode = PreferenceManager.getDefaultSharedPreferences(context)
.getString(context.getString(R.string.list_view_mode_key),
context.getString(R.string.list_view_mode_value));
if (listMode.equals(context.getString(R.string.list_view_mode_list_key))) {
return false;
} else if (listMode.equals(context.getString(R.string.list_view_mode_grid_key))) {
return true;
} else {
final Configuration configuration = context.getResources().getConfiguration();
return configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
&& configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE);
}
}
/**
* Calculates the number of grid items that can fit horizontally on the screen. The width of a
* grid item is obtained from the thumbnail width plus the right and left paddings.
*
* @param context the context to use
* @return the span count of grid list items
*/
public static int getGridSpanCount(final Context context) {
final Resources res = context.getResources();
final int minWidth
= res.getDimensionPixelSize(R.dimen.video_item_grid_thumbnail_image_width)
+ res.getDimensionPixelSize(R.dimen.video_item_search_padding) * 2;
return Math.max(1, res.getDisplayMetrics().widthPixels / minWidth);
}
} }

View file

@ -1,15 +1,18 @@
package org.schabi.newpipe.util; package org.schabi.newpipe.util;
import org.schabi.newpipe.streams.io.SharpInputStream;
import java.io.BufferedInputStream; import java.io.BufferedInputStream;
import java.io.File; import java.io.File;
import java.io.FileInputStream; import java.io.FileInputStream;
import java.io.FileOutputStream; import java.io.FileOutputStream;
import java.io.IOException; import java.io.IOException;
import java.util.zip.ZipEntry; import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipInputStream; import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream; import java.util.zip.ZipOutputStream;
import org.schabi.newpipe.streams.io.StoredFileHelper;
/** /**
* Created by Christian Schabesberger on 28.01.18. * Created by Christian Schabesberger on 28.01.18.
* Copyright 2018 Christian Schabesberger <chris.schabesberger@mailbox.org> * Copyright 2018 Christian Schabesberger <chris.schabesberger@mailbox.org>
@ -59,24 +62,23 @@ public final class ZipHelper {
} }
/** /**
* This will extract data from Zipfiles. * This will extract data from ZipInputStream.
* Caution this will override the original file. * 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 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. * @param name The path of the file inside the zip.
* @return will return true if the file was found within the zip file * @return will return true if the file was found within the zip file
* @throws Exception * @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 { final String name) throws Exception {
try (ZipInputStream inZip = new ZipInputStream(new BufferedInputStream( try (ZipInputStream inZip = new ZipInputStream(new BufferedInputStream(
new FileInputStream(filePath)))) { new SharpInputStream(zipFile.getStream())))) {
final byte[] data = new byte[BUFFER_SIZE]; final byte[] data = new byte[BUFFER_SIZE];
boolean found = false; boolean found = false;
ZipEntry ze; ZipEntry ze;
while ((ze = inZip.getNextEntry()) != null) { while ((ze = inZip.getNextEntry()) != null) {
if (ze.getName().equals(name)) { if (ze.getName().equals(name)) {
found = true; found = true;
@ -102,8 +104,9 @@ public final class ZipHelper {
} }
} }
public static boolean isValidZipFile(final String filePath) { public static boolean isValidZipFile(final StoredFileHelper file) {
try (ZipFile ignored = new ZipFile(filePath)) { try (ZipInputStream ignored = new ZipInputStream(new BufferedInputStream(
new SharpInputStream(file.getStream())))) {
return true; return true;
} catch (final IOException ioe) { } catch (final IOException ioe) {
return false; return false;

View file

@ -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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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<StreamInfo> 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;
}
}

View file

@ -1,28 +1,31 @@
package org.schabi.newpipe.util; package org.schabi.newpipe.util.external_communication;
import android.content.Context; import android.content.Context;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AlertDialog;
import androidx.preference.PreferenceManager; import androidx.preference.PreferenceManager;
import org.schabi.newpipe.R; import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.ServiceList; import org.schabi.newpipe.extractor.ServiceList;
import org.schabi.newpipe.util.NavigationHelper;
public final class KoreUtil { public final class KoreUtils {
private KoreUtil() { } private KoreUtils() { }
public static boolean isServiceSupportedByKore(final int serviceId) { public static boolean isServiceSupportedByKore(final int serviceId) {
return (serviceId == ServiceList.YouTube.getServiceId() return (serviceId == ServiceList.YouTube.getServiceId()
|| serviceId == ServiceList.SoundCloud.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) return isServiceSupportedByKore(serviceId)
&& PreferenceManager.getDefaultSharedPreferences(context) && PreferenceManager.getDefaultSharedPreferences(context)
.getBoolean(context.getString(R.string.show_play_with_kodi_key), false); .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); final AlertDialog.Builder builder = new AlertDialog.Builder(context);
builder.setMessage(R.string.kore_not_found) builder.setMessage(R.string.kore_not_found)
.setPositiveButton(R.string.install, (dialog, which) -> .setPositiveButton(R.string.install, (dialog, which) ->

View file

@ -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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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.
* <p>
* If no browser is set as default, fallbacks to
* {@link #openAppChooser(Context, Intent, boolean)}
* <p>
* 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.
* <p>
* The intent can be of every type, excepted a web intent for which
* {@link #openUrlInBrowser(Context, String, boolean)} should be used.
* <p>
* 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.
* <p>
* 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.
* <p>
* If no app is set as default, it will return "android" (not on some devices because some
* OEMs changed the app chooser).
* <p>
* 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.
* <p>
* 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();
}
}

View file

@ -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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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);
}
}

View file

@ -26,7 +26,7 @@ import java.util.Objects;
import javax.net.ssl.SSLException; 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.postprocessing.Postprocessing;
import us.shandian.giga.service.DownloadManagerService; import us.shandian.giga.service.DownloadManagerService;
import us.shandian.giga.util.Utility; import us.shandian.giga.util.Utility;

View file

@ -7,7 +7,7 @@ import org.schabi.newpipe.util.VideoSegment;
import java.io.Serializable; import java.io.Serializable;
import java.util.Calendar; import java.util.Calendar;
import us.shandian.giga.io.StoredFileHelper; import org.schabi.newpipe.streams.io.StoredFileHelper;
public abstract class Mission implements Serializable { public abstract class Mission implements Serializable {
private static final long serialVersionUID = 1L;// last bump: 27 march 2019 private static final long serialVersionUID = 1L;// last bump: 27 march 2019
@ -27,6 +27,10 @@ public abstract class Mission implements Serializable {
*/ */
public long timestamp; public long timestamp;
public long getTimestamp() {
return timestamp;
}
/** /**
* pre-defined content type * pre-defined content type
*/ */
@ -39,10 +43,6 @@ public abstract class Mission implements Serializable {
public String segmentsJson; public String segmentsJson;
public long getTimestamp() {
return timestamp;
}
/** /**
* Delete the downloaded file * Delete the downloaded file
* *
@ -61,7 +61,7 @@ public abstract class Mission implements Serializable {
@NonNull @NonNull
@Override @Override
public String toString() { public String toString() {
Calendar calendar = Calendar.getInstance(); final Calendar calendar = Calendar.getInstance();
calendar.setTimeInMillis(timestamp); calendar.setTimeInMillis(timestamp);
return "[" + calendar.getTime().toString() + "] " + (storage.isInvalid() ? storage.getName() : storage.getUri()); return "[" + calendar.getTime().toString() + "] " + (storage.isInvalid() ? storage.getName() : storage.getUri());
} }

View file

@ -17,7 +17,7 @@ import java.util.Objects;
import us.shandian.giga.get.DownloadMission; import us.shandian.giga.get.DownloadMission;
import us.shandian.giga.get.FinishedMission; import us.shandian.giga.get.FinishedMission;
import us.shandian.giga.get.Mission; 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 * SQLite helper to store finished {@link us.shandian.giga.get.FinishedMission}'s

Some files were not shown because too many files have changed in this diff Show more