mirror of
https://github.com/TeamNewPipe/NewPipe.git
synced 2024-11-22 11:02:35 +01:00
Merge pull request #6576 from TeamNewPipe/release_0.21.6
Release 0.21.6
This commit is contained in:
commit
b795c5f017
63
.github/CONTRIBUTING.md
vendored
63
.github/CONTRIBUTING.md
vendored
@ -3,9 +3,9 @@ NewPipe contribution guidelines
|
||||
|
||||
## Crash reporting
|
||||
|
||||
Report crashes through the automated crash report system of NewPipe.
|
||||
Report crashes through the **automated crash report system** of NewPipe.
|
||||
This way all the data needed for debugging is included in your bugreport for GitHub.
|
||||
You'll see exactly what is sent, be able to add your comments, and then send it.
|
||||
You'll see *exactly* what is sent, be able to add **your comments**, and then send it.
|
||||
|
||||
## Issue reporting/feature requests
|
||||
|
||||
@ -25,22 +25,57 @@ You'll see exactly what is sent, be able to add your comments, and then send it.
|
||||
|
||||
## Code contribution
|
||||
|
||||
* If you want to help out with an existing bug report or feature request, leave a comment on that issue saying you want to try your hand at it.
|
||||
* If there is no existing issue for what you want to work on, open a new one describing your changes. This gives the team and the community a chance to give feedback before you spend time on something that is already in development, should be done differently, or should be avoided completely.
|
||||
* Stick to NewPipe's style conventions of [checkStyle](https://github.com/checkstyle/checkstyle). It runs each time you build the project.
|
||||
* Do not bring non-free software (e.g. binary blobs) into the project. Make sure you do not introduce Google
|
||||
libraries.
|
||||
### Guidelines
|
||||
|
||||
* Stick to NewPipe's *style conventions* of [checkStyle](https://github.com/checkstyle/checkstyle) and [ktlint](https://github.com/pinterest/ktlint). They run each time you build the project.
|
||||
* Stick to [F-Droid contribution guidelines](https://f-droid.org/wiki/page/Inclusion_Policy).
|
||||
* Make changes on a separate branch with a meaningful name, not on the _master_ branch or the _dev_ branch. This is commonly known as *feature branch workflow*. You may then send your changes as a pull request (PR) on GitHub.
|
||||
* Please test (compile and run) your code before submitting changes! Ideally, provide test feedback in the PR description. Untested code will **not** be merged!
|
||||
* Make sure your PR is up-to-date with the rest of the code. Often, a simple click on "Update branch" will do the job, but if not, you must rebase the dev branch manually and resolve the problems on your own. You can find help [on the wiki](https://github.com/TeamNewPipe/NewPipe/wiki/How-to-merge-a-PR). That makes the maintainers' jobs way easier.
|
||||
* Please show intention to maintain your features and code after you contribute a PR. Unmaintained code is a hassle for core developers. If you do not intend to maintain features you plan to contribute, please rethink your submission, or clearly state that in the PR description.
|
||||
* In particular **do not bring non-free software** (e.g. binary blobs) into the project. Make sure you do not introduce any closed-source library from Google.
|
||||
|
||||
### Before starting development
|
||||
|
||||
* If you want to help out with an existing bug report or feature request, **leave a comment** on that issue saying you want to try your hand at it.
|
||||
* If there is no existing issue for what you want to work on, **open a new one** describing the changes you are planning to introduce. This gives the team and the community a chance to give **feedback** before you spend time on something that is already in development, should be done differently, or should be avoided completely.
|
||||
* Please show **intention to maintain your features** and code after you contribute a PR. Unmaintained code is a hassle for core developers. If you do not intend to maintain features you plan to contribute, please rethink your submission, or clearly state that in the PR description.
|
||||
* Create PRs that cover only **one specific issue/solution/bug**. Do not create PRs that are huge monoliths and could have been split into multiple independent contributions.
|
||||
* NewPipe uses [NewPipeExtractor](https://github.com/TeamNewPipe/NewPipeExtractor) to fetch data from services. If you need to change something there, you must test your changes in NewPipe. Telling NewPipe to use your extractor version can be accomplished by editing the `app/build.gradle` file: the comments under the "NewPipe libraries" section of `dependencies` will help you out.
|
||||
|
||||
### Creating a Pull Request (PR)
|
||||
|
||||
* Make changes on a **separate branch** with a meaningful name, not on the _master_ branch or the _dev_ branch. This is commonly known as *feature branch workflow*. You may then send your changes as a pull request (PR) on GitHub.
|
||||
* Please **test** (compile and run) your code before submitting changes! Ideally, provide test feedback in the PR description. Untested code will **not** be merged!
|
||||
* Respond if someone requests changes or otherwise raises issues about your PRs.
|
||||
* Send PRs that only cover one specific issue/solution/bug. Do not send PRs that are huge and consist of multiple independent solutions.
|
||||
* Try to figure out yourself why builds on our CI fail.
|
||||
* Make sure your PR is **up-to-date** with the rest of the code. Often, a simple click on "Update branch" will do the job, but if not, you must *rebase* your branch on the `dev` branch manually and resolve the conflicts on your own. You can find help [on the wiki](https://github.com/TeamNewPipe/NewPipe/wiki/How-to-merge-a-PR). Doing this makes the maintainers' job way easier.
|
||||
|
||||
## IDE setup & building the app
|
||||
|
||||
### Basic setup
|
||||
|
||||
NewPipe is developed using [Android Studio](https://developer.android.com/studio/). Learn more about how to install it and how it works in the [official documentation](https://developer.android.com/studio/intro). In particular, make sure you have accepted Android Studio's SDK licences. Once Android Studio is ready, setting up the NewPipe project is fairly simple:
|
||||
- Clone the NewPipe repository with `git clone https://github.com/TeamNewPipe/NewPipe.git` (or use the link from your own fork, if you want to open a PR).
|
||||
- Open the folder you just cloned with Android Studio.
|
||||
- Build and run it just like you would do with any other app, with the green triangle in the top bar.
|
||||
|
||||
You may find [SonarLint](https://www.sonarlint.org/intellij)'s **inspections** useful in helping you to write good code and prevent bugs.
|
||||
|
||||
### checkStyle setup
|
||||
|
||||
The [checkStyle](https://github.com/checkstyle/checkstyle) plugin verifies that Java code abides by the project style. It runs automatically each time you build the project. If you want to view errors directly in the editor, instead of having to skim through the build output, you can install an Android Studio plugin:
|
||||
- Go to `File -> Settings -> Plugins`, search for `checkstyle` and install `CheckStyle-IDEA`.
|
||||
- Go to `File -> Settings -> Tools -> Checkstyle`.
|
||||
- Add NewPipe's configuration file by clicking the `+` in the right toolbar of the "Configuration File" list.
|
||||
- Under the "Use a local Checkstyle file" bullet, click on `Browse` and pick the file named `checkstyle.xml` in the project's root folder.
|
||||
- Enable "Store relative to project location" so that moving the directory around does not create issues.
|
||||
- Insert a description in the top bar, then click `Next` and then `Finish`.
|
||||
- Activate the configuration file you just added by enabling the checkbox on the left.
|
||||
- Click `Ok` and you are done.
|
||||
|
||||
### ktlint setup
|
||||
|
||||
The [ktlint](https://github.com/pinterest/ktlint) plugin does the same job as checkStyle for Kotlin files. Installing the related plugin is as simple as going to `File -> Settings -> Plugins`, searching for `ktlint` and installing `Ktlint (unofficial)`.
|
||||
|
||||
## Communication
|
||||
|
||||
* The #newpipe channel on Libera Chat (`ircs://irc.libera.chat:6697/newpipe`) has the core team and other developers in it. [Click here for webchat](https://web.libera.chat/#newpipe)!
|
||||
* You can also use a Matrix account to join the NewPipe channel at [#newpipe:libera.chat](https://matrix.to/#/#newpipe:libera.chat).
|
||||
* Post suggestions, changes, ideas etc. on GitHub or IRC.
|
||||
* You can also use a Matrix account to join the NewPipe channel at [#newpipe:libera.chat](https://matrix.to/#/#newpipe:libera.chat). Some convenient clients, available both for phone and desktop, are listed at that link.
|
||||
* You can post your suggestions, changes, ideas etc. on either GitHub or IRC.
|
||||
|
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -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. -->
|
||||
|
||||
|
||||
|
24
.github/ISSUE_TEMPLATE/question.md
vendored
Normal file
24
.github/ISSUE_TEMPLATE/question.md
vendored
Normal 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!* -->
|
9
.github/PULL_REQUEST_TEMPLATE.md
vendored
9
.github/PULL_REQUEST_TEMPLATE.md
vendored
@ -12,18 +12,23 @@
|
||||
- create clones
|
||||
- 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)
|
||||
<!-- 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 #
|
||||
|
||||
#### 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
|
||||
<!-- 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.-->
|
||||
On the website the APK can be found by going to the "Checks" tab below the title and then on "artifacts" on the right.
|
||||
The APK can be found by going to the "Checks" tab below the title. On the left pane, click on "CI", scroll down to "artifacts" and click "app" to download the zip file which contains the debug APK of this PR.
|
||||
|
||||
#### Due diligence
|
||||
- [ ] I read the [contribution guidelines](https://github.com/TeamNewPipe/NewPipe/blob/HEAD/.github/CONTRIBUTING.md).
|
||||
|
53
.github/workflows/ci.yml
vendored
53
.github/workflows/ci.yml
vendored
@ -43,32 +43,37 @@ jobs:
|
||||
with:
|
||||
name: app
|
||||
path: app/build/outputs/apk/debug/*.apk
|
||||
test-android:
|
||||
runs-on: macos-latest
|
||||
strategy:
|
||||
matrix:
|
||||
api-level: [21, 29]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: set up JDK 8
|
||||
uses: actions/setup-java@v2
|
||||
with:
|
||||
java-version: 8
|
||||
distribution: "adopt"
|
||||
# Disabled until emulator works again. see https://github.com/TeamNewPipe/NewPipe/pull/6560
|
||||
# test-android:
|
||||
# macos has hardware acceleration. See android-emulator-runner action
|
||||
# runs-on: macos-latest
|
||||
# strategy:
|
||||
# matrix:
|
||||
# api-level 19 is min sdk, but throws errors related to desugaring
|
||||
# api-level: [21, 29]
|
||||
# steps:
|
||||
# - uses: actions/checkout@v2
|
||||
#
|
||||
# - name: set up JDK 8
|
||||
# uses: actions/setup-java@v2
|
||||
# with:
|
||||
# java-version: 8
|
||||
# distribution: "adopt"
|
||||
#
|
||||
# - name: Cache Gradle dependencies
|
||||
# uses: actions/cache@v2
|
||||
# with:
|
||||
# path: ~/.gradle/caches
|
||||
# key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }}
|
||||
# restore-keys: ${{ runner.os }}-gradle
|
||||
#
|
||||
# - name: Run android tests
|
||||
# uses: reactivecircus/android-emulator-runner@v2
|
||||
# with:
|
||||
# api-level: ${{ matrix.api-level }}
|
||||
# script: ./gradlew connectedCheck
|
||||
|
||||
- name: Cache Gradle dependencies
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ~/.gradle/caches
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }}
|
||||
restore-keys: ${{ runner.os }}-gradle
|
||||
|
||||
- name: Run android tests
|
||||
uses: reactivecircus/android-emulator-runner@v2
|
||||
with:
|
||||
api-level: ${{ matrix.api-level }}
|
||||
script: ./gradlew connectedCheck
|
||||
# sonar:
|
||||
# runs-on: ubuntu-latest
|
||||
# steps:
|
||||
|
@ -17,8 +17,8 @@ android {
|
||||
resValue "string", "app_name", "NewPipe"
|
||||
minSdkVersion 19
|
||||
targetSdkVersion 29
|
||||
versionCode 971
|
||||
versionName "0.21.5"
|
||||
versionCode 972
|
||||
versionName "0.21.6"
|
||||
|
||||
multiDexEnabled true
|
||||
|
||||
@ -102,7 +102,7 @@ ext {
|
||||
checkstyleVersion = '8.38'
|
||||
|
||||
androidxLifecycleVersion = '2.2.0'
|
||||
androidxRoomVersion = '2.3.0-alpha03'
|
||||
androidxRoomVersion = '2.3.0'
|
||||
|
||||
icepickVersion = '3.2.0'
|
||||
exoPlayerVersion = '2.12.3'
|
||||
@ -182,8 +182,11 @@ dependencies {
|
||||
|
||||
/** NewPipe libraries **/
|
||||
// You can use a local version by uncommenting a few lines in settings.gradle
|
||||
// Or you can use a commit you pushed to GitHub by just replacing TeamNewPipe with your GitHub
|
||||
// name and the commit hash with the commit hash of the (pushed) commit you want to test
|
||||
// This works thanks to JitPack: https://jitpack.io/
|
||||
implementation 'com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751'
|
||||
implementation 'com.github.TeamNewPipe:NewPipeExtractor:v0.21.4'
|
||||
implementation 'com.github.TeamNewPipe:NewPipeExtractor:v0.21.6'
|
||||
|
||||
/** Checkstyle **/
|
||||
checkstyle "com.puppycrawl.tools:checkstyle:${checkstyleVersion}"
|
||||
@ -198,6 +201,7 @@ dependencies {
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
|
||||
implementation 'androidx.core:core-ktx:1.3.2'
|
||||
implementation 'androidx.documentfile:documentfile:1.0.1'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.3.4'
|
||||
implementation "androidx.lifecycle:lifecycle-livedata:${androidxLifecycleVersion}"
|
||||
implementation "androidx.lifecycle:lifecycle-viewmodel:${androidxLifecycleVersion}"
|
||||
implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.0.0'
|
||||
|
@ -22,7 +22,6 @@
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:logo="@mipmap/ic_launcher"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:theme="@style/OpeningTheme"
|
||||
android:resizeableActivity="true"
|
||||
tools:ignore="AllowBackup">
|
||||
@ -232,11 +231,10 @@
|
||||
<data android:host="invidious.snopyta.org" />
|
||||
<data android:host="yewtu.be" />
|
||||
<data android:host="tube.connect.cafe" />
|
||||
<data android:host="invidious.zapashcanon.fr" />
|
||||
<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.xyz" />
|
||||
<data android:host="vid.mint.lgbt" />
|
||||
<data android:host="invidiou.site" />
|
||||
<data android:host="invidious.fdn.fr" />
|
||||
@ -244,6 +242,15 @@
|
||||
<data android:host="invidious.zee.li" />
|
||||
<data android:host="vid.puffyan.us" />
|
||||
<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="/" />
|
||||
</intent-filter>
|
||||
|
||||
|
@ -63,8 +63,9 @@ public final class FlingBehavior extends AppBarLayout.Behavior {
|
||||
return consumed == dy;
|
||||
}
|
||||
|
||||
public boolean onInterceptTouchEvent(final CoordinatorLayout parent, final AppBarLayout child,
|
||||
final MotionEvent ev) {
|
||||
public boolean onInterceptTouchEvent(@NonNull final CoordinatorLayout parent,
|
||||
@NonNull final AppBarLayout child,
|
||||
@NonNull final MotionEvent ev) {
|
||||
for (final Integer element : skipInterceptionOfElements) {
|
||||
final View view = child.findViewById(element);
|
||||
if (view != null) {
|
||||
|
@ -27,7 +27,7 @@ import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.extractor.NewPipe;
|
||||
import org.schabi.newpipe.extractor.downloader.Downloader;
|
||||
import org.schabi.newpipe.ktx.ExceptionUtils;
|
||||
import org.schabi.newpipe.settings.SettingsActivity;
|
||||
import org.schabi.newpipe.settings.NewPipeSettings;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
import org.schabi.newpipe.util.ServiceHelper;
|
||||
import org.schabi.newpipe.util.StateSaver;
|
||||
@ -91,7 +91,7 @@ public class App extends MultiDexApplication {
|
||||
app = this;
|
||||
|
||||
// Initialize settings first because others inits can use its values
|
||||
SettingsActivity.initSettings(this);
|
||||
NewPipeSettings.initSettings(this);
|
||||
|
||||
NewPipe.init(getDownloader(),
|
||||
Localization.getPreferredLocalization(this),
|
||||
|
@ -129,13 +129,8 @@ public final class CheckForNewAppVersion {
|
||||
|
||||
if (BuildConfig.VERSION_CODE < versionCode) {
|
||||
// A pending intent to open the apk location url in the browser.
|
||||
final Intent viewIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(apkLocationUrl));
|
||||
|
||||
final Intent intent = new Intent(Intent.ACTION_CHOOSER);
|
||||
intent.putExtra(Intent.EXTRA_INTENT, viewIntent);
|
||||
intent.putExtra(Intent.EXTRA_TITLE, R.string.open_with);
|
||||
final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(apkLocationUrl));
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
|
||||
final PendingIntent pendingIntent
|
||||
= PendingIntent.getActivity(application, 0, intent, 0);
|
||||
|
||||
|
@ -6,6 +6,8 @@ import android.content.Intent;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
|
||||
/*
|
||||
* Copyright (C) Hans-Christoph Steiner 2016 <hans@eds.org>
|
||||
* ExitActivity.java is part of NewPipe.
|
||||
@ -48,6 +50,6 @@ public class ExitActivity extends Activity {
|
||||
finish();
|
||||
}
|
||||
|
||||
System.exit(0);
|
||||
NavigationHelper.restartApp(this);
|
||||
}
|
||||
}
|
||||
|
@ -603,6 +603,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
public void onRequestPermissionsResult(final int requestCode,
|
||||
@NonNull final String[] permissions,
|
||||
@NonNull final int[] grantResults) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||
for (final int i : grantResults) {
|
||||
if (i == PackageManager.PERMISSION_DENIED) {
|
||||
return;
|
||||
|
@ -51,4 +51,15 @@ public final class NewPipeDatabase {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -69,7 +69,7 @@ import org.schabi.newpipe.util.ExtractorHelper;
|
||||
import org.schabi.newpipe.util.ListHelper;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.PermissionHelper;
|
||||
import org.schabi.newpipe.util.ShareUtils;
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
import org.schabi.newpipe.util.urlfinder.UrlFinder;
|
||||
import org.schabi.newpipe.views.FocusOverlayView;
|
||||
@ -107,6 +107,7 @@ public class RouterActivity extends AppCompatActivity {
|
||||
protected String currentUrl;
|
||||
private StreamingService currentService;
|
||||
private boolean selectionIsDownload = false;
|
||||
private AlertDialog alertDialogChoice = null;
|
||||
|
||||
@Override
|
||||
protected void onCreate(final Bundle savedInstanceState) {
|
||||
@ -126,6 +127,15 @@ public class RouterActivity extends AppCompatActivity {
|
||||
? R.style.RouterActivityThemeLight : R.style.RouterActivityThemeDark);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStop() {
|
||||
super.onStop();
|
||||
// we need to dismiss the dialog before leaving the activity or we get leaks
|
||||
if (alertDialogChoice != null) {
|
||||
alertDialogChoice.dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onSaveInstanceState(@NonNull final Bundle outState) {
|
||||
super.onSaveInstanceState(outState);
|
||||
@ -333,7 +343,7 @@ 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)
|
||||
.setView(radioGroup)
|
||||
.setCancelable(true)
|
||||
@ -347,12 +357,12 @@ public class RouterActivity extends AppCompatActivity {
|
||||
.create();
|
||||
|
||||
//noinspection CodeBlock2Expr
|
||||
alertDialog.setOnShowListener(dialog -> {
|
||||
setDialogButtonsState(alertDialog, radioGroup.getCheckedRadioButtonId() != -1);
|
||||
alertDialogChoice.setOnShowListener(dialog -> {
|
||||
setDialogButtonsState(alertDialogChoice, radioGroup.getCheckedRadioButtonId() != -1);
|
||||
});
|
||||
|
||||
radioGroup.setOnCheckedChangeListener((group, checkedId) ->
|
||||
setDialogButtonsState(alertDialog, true));
|
||||
setDialogButtonsState(alertDialogChoice, true));
|
||||
final View.OnClickListener radioButtonsClickListener = v -> {
|
||||
final int indexOfChild = radioGroup.indexOfChild(v);
|
||||
if (indexOfChild == -1) {
|
||||
@ -402,10 +412,10 @@ public class RouterActivity extends AppCompatActivity {
|
||||
}
|
||||
selectedPreviously = selectedRadioPosition;
|
||||
|
||||
alertDialog.show();
|
||||
alertDialogChoice.show();
|
||||
|
||||
if (DeviceUtils.isTv(this)) {
|
||||
FocusOverlayView.setupFocusObserver(alertDialog);
|
||||
FocusOverlayView.setupFocusObserver(alertDialogChoice);
|
||||
}
|
||||
}
|
||||
|
||||
@ -590,6 +600,7 @@ public class RouterActivity extends AppCompatActivity {
|
||||
public void onRequestPermissionsResult(final int requestCode,
|
||||
@NonNull final String[] permissions,
|
||||
@NonNull final int[] grantResults) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||
for (final int i : grantResults) {
|
||||
if (i == PackageManager.PERMISSION_DENIED) {
|
||||
finish();
|
||||
|
@ -17,8 +17,8 @@ import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.databinding.ActivityAboutBinding
|
||||
import org.schabi.newpipe.databinding.FragmentAboutBinding
|
||||
import org.schabi.newpipe.util.Localization
|
||||
import org.schabi.newpipe.util.ShareUtils
|
||||
import org.schabi.newpipe.util.ThemeHelper
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils
|
||||
|
||||
class AboutActivity : AppCompatActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
|
@ -1,10 +1,7 @@
|
||||
package org.schabi.newpipe.about
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.ContextMenu
|
||||
import android.view.ContextMenu.ContextMenuInfo
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.os.bundleOf
|
||||
@ -14,7 +11,6 @@ import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.about.LicenseFragmentHelper.showLicense
|
||||
import org.schabi.newpipe.databinding.FragmentLicensesBinding
|
||||
import org.schabi.newpipe.databinding.ItemSoftwareComponentBinding
|
||||
import org.schabi.newpipe.util.ShareUtils
|
||||
import java.util.Arrays
|
||||
import java.util.Objects
|
||||
|
||||
@ -23,7 +19,6 @@ import java.util.Objects
|
||||
*/
|
||||
class LicenseFragment : Fragment() {
|
||||
private lateinit var softwareComponents: Array<SoftwareComponent>
|
||||
private var componentForContextMenu: SoftwareComponent? = null
|
||||
private var activeLicense: License? = null
|
||||
private val compositeDisposable = CompositeDisposable()
|
||||
|
||||
@ -73,7 +68,7 @@ class LicenseFragment : Fragment() {
|
||||
root.setOnClickListener {
|
||||
activeLicense = component.license
|
||||
compositeDisposable.add(
|
||||
showLicense(activity, component.license)
|
||||
showLicense(activity, component)
|
||||
)
|
||||
}
|
||||
binding.licensesSoftwareComponents.addView(root)
|
||||
@ -87,30 +82,6 @@ class LicenseFragment : Fragment() {
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onCreateContextMenu(menu: ContextMenu, v: View, menuInfo: ContextMenuInfo?) {
|
||||
val inflater = requireActivity().menuInflater
|
||||
val component = v.tag as SoftwareComponent
|
||||
menu.setHeaderTitle(component.name)
|
||||
inflater.inflate(R.menu.software_component, menu)
|
||||
super.onCreateContextMenu(menu, v, menuInfo)
|
||||
componentForContextMenu = component
|
||||
}
|
||||
|
||||
override fun onContextItemSelected(item: MenuItem): Boolean {
|
||||
// item.getMenuInfo() is null so we use the tag of the view
|
||||
val component = componentForContextMenu ?: return false
|
||||
when (item.itemId) {
|
||||
R.id.menu_software_website -> {
|
||||
ShareUtils.openUrlInBrowser(activity, component.link)
|
||||
return true
|
||||
}
|
||||
R.id.menu_software_show_license -> compositeDisposable.add(
|
||||
showLicense(activity, component.license)
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(savedInstanceState: Bundle) {
|
||||
super.onSaveInstanceState(savedInstanceState)
|
||||
if (activeLicense != null) {
|
||||
|
@ -11,6 +11,7 @@ import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.util.Localization
|
||||
import org.schabi.newpipe.util.ThemeHelper
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils
|
||||
import java.io.BufferedReader
|
||||
import java.io.IOException
|
||||
import java.io.InputStreamReader
|
||||
@ -113,4 +114,34 @@ object LicenseFragmentHelper {
|
||||
}
|
||||
}
|
||||
}
|
||||
@JvmStatic
|
||||
fun showLicense(context: Context?, component: SoftwareComponent): Disposable {
|
||||
return if (context == null) {
|
||||
Disposable.empty()
|
||||
} else {
|
||||
Observable.fromCallable { getFormattedLicense(context, component.license) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { formattedLicense: String ->
|
||||
val webViewData = Base64.encodeToString(
|
||||
formattedLicense
|
||||
.toByteArray(StandardCharsets.UTF_8),
|
||||
Base64.NO_PADDING
|
||||
)
|
||||
val webView = WebView(context)
|
||||
webView.loadData(webViewData, "text/html; charset=UTF-8", "base64")
|
||||
val alert = AlertDialog.Builder(context)
|
||||
alert.setTitle(component.license.name)
|
||||
alert.setView(webView)
|
||||
Localization.assureCorrectAppLanguage(context)
|
||||
alert.setPositiveButton(
|
||||
R.string.dismiss
|
||||
) { dialog, _ -> dialog.dismiss() }
|
||||
alert.setNeutralButton(R.string.open_website_license) { _, _ ->
|
||||
ShareUtils.openUrlInBrowser(context, component.link)
|
||||
}
|
||||
alert.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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 + "\"");
|
||||
}
|
||||
}
|
52
app/src/main/java/org/schabi/newpipe/database/Converters.kt
Normal file
52
app/src/main/java/org/schabi/newpipe/database/Converters.kt
Normal 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 }
|
||||
}
|
||||
}
|
@ -9,7 +9,8 @@ import androidx.room.Update
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import org.schabi.newpipe.database.feed.model.FeedEntity
|
||||
import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||
import org.schabi.newpipe.database.stream.StreamWithState
|
||||
import org.schabi.newpipe.database.stream.model.StreamStateEntity
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
@ -20,21 +21,34 @@ abstract class FeedDAO {
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT s.* FROM streams s
|
||||
SELECT s.*, sst.progress_time
|
||||
FROM streams s
|
||||
|
||||
LEFT JOIN stream_state sst
|
||||
ON s.uid = sst.stream_id
|
||||
|
||||
LEFT JOIN stream_history sh
|
||||
ON s.uid = sh.stream_id
|
||||
|
||||
INNER JOIN feed f
|
||||
ON s.uid = f.stream_id
|
||||
|
||||
ORDER BY s.upload_date IS NULL DESC, s.upload_date DESC, s.uploader ASC
|
||||
|
||||
LIMIT 500
|
||||
"""
|
||||
)
|
||||
abstract fun getAllStreams(): Flowable<List<StreamEntity>>
|
||||
abstract fun getAllStreams(): Flowable<List<StreamWithState>>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT s.* FROM streams s
|
||||
SELECT s.*, sst.progress_time
|
||||
FROM streams s
|
||||
|
||||
LEFT JOIN stream_state sst
|
||||
ON s.uid = sst.stream_id
|
||||
|
||||
LEFT JOIN stream_history sh
|
||||
ON s.uid = sh.stream_id
|
||||
|
||||
INNER JOIN feed f
|
||||
ON s.uid = f.stream_id
|
||||
@ -42,16 +56,88 @@ abstract class FeedDAO {
|
||||
INNER JOIN feed_group_subscription_join fgs
|
||||
ON fgs.subscription_id = f.subscription_id
|
||||
|
||||
INNER JOIN feed_group fg
|
||||
ON fg.uid = fgs.group_id
|
||||
|
||||
WHERE fgs.group_id = :groupId
|
||||
|
||||
ORDER BY s.upload_date IS NULL DESC, s.upload_date DESC, s.uploader ASC
|
||||
LIMIT 500
|
||||
"""
|
||||
)
|
||||
abstract fun 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(
|
||||
"""
|
||||
|
@ -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_TABLE;
|
||||
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_STREAM_ID_ALIAS;
|
||||
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_PROGRESS_TIME;
|
||||
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_PROGRESS_MILLIS;
|
||||
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE;
|
||||
|
||||
@Dao
|
||||
@ -80,7 +80,7 @@ public abstract class StreamHistoryDAO implements HistoryDAO<StreamHistoryEntity
|
||||
|
||||
+ " LEFT JOIN "
|
||||
+ "(SELECT " + JOIN_STREAM_ID + " AS " + JOIN_STREAM_ID_ALIAS + ", "
|
||||
+ STREAM_PROGRESS_TIME
|
||||
+ STREAM_PROGRESS_MILLIS
|
||||
+ " FROM " + STREAM_STATE_TABLE + " )"
|
||||
+ " ON " + STREAM_ID + " = " + JOIN_STREAM_ID_ALIAS)
|
||||
public abstract Flowable<List<StreamStatisticsEntry>> getStatistics();
|
||||
|
@ -12,8 +12,8 @@ data class PlaylistStreamEntry(
|
||||
@Embedded
|
||||
val streamEntity: StreamEntity,
|
||||
|
||||
@ColumnInfo(name = StreamStateEntity.STREAM_PROGRESS_TIME, defaultValue = "0")
|
||||
val progressTime: Long,
|
||||
@ColumnInfo(name = StreamStateEntity.STREAM_PROGRESS_MILLIS, defaultValue = "0")
|
||||
val progressMillis: Long,
|
||||
|
||||
@ColumnInfo(name = PlaylistStreamEntity.JOIN_STREAM_ID)
|
||||
val streamId: Long,
|
||||
|
@ -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;
|
||||
|
||||
@Dao
|
||||
public abstract class PlaylistDAO implements BasicDAO<PlaylistEntity> {
|
||||
public interface PlaylistDAO extends BasicDAO<PlaylistEntity> {
|
||||
@Override
|
||||
@Query("SELECT * FROM " + PLAYLIST_TABLE)
|
||||
public abstract Flowable<List<PlaylistEntity>> getAll();
|
||||
Flowable<List<PlaylistEntity>> getAll();
|
||||
|
||||
@Override
|
||||
@Query("DELETE FROM " + PLAYLIST_TABLE)
|
||||
public abstract int deleteAll();
|
||||
int deleteAll();
|
||||
|
||||
@Override
|
||||
public Flowable<List<PlaylistEntity>> listByService(final int serviceId) {
|
||||
default Flowable<List<PlaylistEntity>> listByService(final int serviceId) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@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")
|
||||
public abstract int deletePlaylist(long playlistId);
|
||||
int deletePlaylist(long playlistId);
|
||||
|
||||
@Query("SELECT COUNT(*) FROM " + PLAYLIST_TABLE)
|
||||
public abstract Flowable<Long> getCount();
|
||||
Flowable<Long> getCount();
|
||||
}
|
||||
|
@ -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;
|
||||
|
||||
@Dao
|
||||
public abstract class PlaylistRemoteDAO implements BasicDAO<PlaylistRemoteEntity> {
|
||||
public interface PlaylistRemoteDAO extends BasicDAO<PlaylistRemoteEntity> {
|
||||
@Override
|
||||
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE)
|
||||
public abstract Flowable<List<PlaylistRemoteEntity>> getAll();
|
||||
Flowable<List<PlaylistRemoteEntity>> getAll();
|
||||
|
||||
@Override
|
||||
@Query("DELETE FROM " + REMOTE_PLAYLIST_TABLE)
|
||||
public abstract int deleteAll();
|
||||
int deleteAll();
|
||||
|
||||
@Override
|
||||
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE
|
||||
+ " WHERE " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId")
|
||||
public abstract Flowable<List<PlaylistRemoteEntity>> listByService(int serviceId);
|
||||
Flowable<List<PlaylistRemoteEntity>> listByService(int serviceId);
|
||||
|
||||
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE "
|
||||
+ 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
|
||||
+ " WHERE " + REMOTE_PLAYLIST_URL + " = :url "
|
||||
+ "AND " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId")
|
||||
abstract Long getPlaylistIdInternal(long serviceId, String url);
|
||||
Long getPlaylistIdInternal(long serviceId, String url);
|
||||
|
||||
@Transaction
|
||||
public long upsert(final PlaylistRemoteEntity playlist) {
|
||||
default long upsert(final PlaylistRemoteEntity playlist) {
|
||||
final Long playlistId = getPlaylistIdInternal(playlist.getServiceId(), playlist.getUrl());
|
||||
|
||||
if (playlistId == null) {
|
||||
@ -55,5 +55,5 @@ public abstract class PlaylistRemoteDAO implements BasicDAO<PlaylistRemoteEntity
|
||||
|
||||
@Query("DELETE FROM " + REMOTE_PLAYLIST_TABLE
|
||||
+ " WHERE " + REMOTE_PLAYLIST_ID + " = :playlistId")
|
||||
public abstract int deletePlaylist(long playlistId);
|
||||
int deletePlaylist(long playlistId);
|
||||
}
|
||||
|
@ -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_TABLE;
|
||||
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_STREAM_ID_ALIAS;
|
||||
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_PROGRESS_TIME;
|
||||
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_PROGRESS_MILLIS;
|
||||
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE;
|
||||
|
||||
@Dao
|
||||
public abstract class PlaylistStreamDAO implements BasicDAO<PlaylistStreamEntity> {
|
||||
public interface PlaylistStreamDAO extends BasicDAO<PlaylistStreamEntity> {
|
||||
@Override
|
||||
@Query("SELECT * FROM " + PLAYLIST_STREAM_JOIN_TABLE)
|
||||
public abstract Flowable<List<PlaylistStreamEntity>> getAll();
|
||||
Flowable<List<PlaylistStreamEntity>> getAll();
|
||||
|
||||
@Override
|
||||
@Query("DELETE FROM " + PLAYLIST_STREAM_JOIN_TABLE)
|
||||
public abstract int deleteAll();
|
||||
int deleteAll();
|
||||
|
||||
@Override
|
||||
public Flowable<List<PlaylistStreamEntity>> listByService(final int serviceId) {
|
||||
default Flowable<List<PlaylistStreamEntity>> listByService(final int serviceId) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Query("DELETE FROM " + PLAYLIST_STREAM_JOIN_TABLE
|
||||
+ " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId")
|
||||
public abstract void deleteBatch(long playlistId);
|
||||
void deleteBatch(long playlistId);
|
||||
|
||||
@Query("SELECT COALESCE(MAX(" + JOIN_INDEX + "), -1)"
|
||||
+ " FROM " + PLAYLIST_STREAM_JOIN_TABLE
|
||||
+ " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId")
|
||||
public abstract Flowable<Integer> getMaximumIndexOf(long playlistId);
|
||||
Flowable<Integer> getMaximumIndexOf(long playlistId);
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM " + STREAM_TABLE + " INNER JOIN "
|
||||
@ -64,12 +64,12 @@ public abstract class PlaylistStreamDAO implements BasicDAO<PlaylistStreamEntity
|
||||
|
||||
+ " LEFT JOIN "
|
||||
+ "(SELECT " + JOIN_STREAM_ID + " AS " + JOIN_STREAM_ID_ALIAS + ", "
|
||||
+ STREAM_PROGRESS_TIME
|
||||
+ STREAM_PROGRESS_MILLIS
|
||||
+ " FROM " + STREAM_STATE_TABLE + " )"
|
||||
+ " ON " + STREAM_ID + " = " + JOIN_STREAM_ID_ALIAS
|
||||
|
||||
+ " ORDER BY " + JOIN_INDEX + " ASC")
|
||||
public abstract Flowable<List<PlaylistStreamEntry>> getOrderedStreamsOf(long playlistId);
|
||||
Flowable<List<PlaylistStreamEntry>> getOrderedStreamsOf(long playlistId);
|
||||
|
||||
@Transaction
|
||||
@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
|
||||
+ " GROUP BY " + JOIN_PLAYLIST_ID
|
||||
+ " ORDER BY " + PLAYLIST_NAME + " COLLATE NOCASE ASC")
|
||||
public abstract Flowable<List<PlaylistMetadataEntry>> getPlaylistMetadata();
|
||||
Flowable<List<PlaylistMetadataEntry>> getPlaylistMetadata();
|
||||
}
|
||||
|
@ -5,7 +5,7 @@ import androidx.room.Embedded
|
||||
import org.schabi.newpipe.database.LocalItem
|
||||
import org.schabi.newpipe.database.history.model.StreamHistoryEntity
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||
import org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_PROGRESS_TIME
|
||||
import org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_PROGRESS_MILLIS
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
@ -13,8 +13,8 @@ class StreamStatisticsEntry(
|
||||
@Embedded
|
||||
val streamEntity: StreamEntity,
|
||||
|
||||
@ColumnInfo(name = STREAM_PROGRESS_TIME, defaultValue = "0")
|
||||
val progressTime: Long,
|
||||
@ColumnInfo(name = STREAM_PROGRESS_MILLIS, defaultValue = "0")
|
||||
val progressMillis: Long,
|
||||
|
||||
@ColumnInfo(name = StreamHistoryEntity.JOIN_STREAM_ID)
|
||||
val streamId: Long,
|
||||
|
@ -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?
|
||||
)
|
@ -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;
|
||||
|
||||
@Dao
|
||||
public abstract class StreamStateDAO implements BasicDAO<StreamStateEntity> {
|
||||
public interface StreamStateDAO extends BasicDAO<StreamStateEntity> {
|
||||
@Override
|
||||
@Query("SELECT * FROM " + STREAM_STATE_TABLE)
|
||||
public abstract Flowable<List<StreamStateEntity>> getAll();
|
||||
Flowable<List<StreamStateEntity>> getAll();
|
||||
|
||||
@Override
|
||||
@Query("DELETE FROM " + STREAM_STATE_TABLE)
|
||||
public abstract int deleteAll();
|
||||
int deleteAll();
|
||||
|
||||
@Override
|
||||
public Flowable<List<StreamStateEntity>> listByService(final int serviceId) {
|
||||
default Flowable<List<StreamStateEntity>> listByService(final int serviceId) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@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")
|
||||
public abstract int deleteState(long streamId);
|
||||
int deleteState(long streamId);
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
abstract void silentInsertInternal(StreamStateEntity streamState);
|
||||
void silentInsertInternal(StreamStateEntity streamState);
|
||||
|
||||
@Transaction
|
||||
public long upsert(final StreamStateEntity stream) {
|
||||
default long upsert(final StreamStateEntity stream) {
|
||||
silentInsertInternal(stream);
|
||||
return update(stream);
|
||||
}
|
||||
|
@ -5,7 +5,7 @@ import androidx.room.ColumnInfo;
|
||||
import androidx.room.Entity;
|
||||
import androidx.room.ForeignKey;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.Objects;
|
||||
|
||||
import static androidx.room.ForeignKey.CASCADE;
|
||||
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_STREAM_ID;
|
||||
@ -25,26 +25,31 @@ public class StreamStateEntity {
|
||||
// This additional field is required for the SQL query because 'stream_id' is used
|
||||
// for some other joins already
|
||||
public static final String JOIN_STREAM_ID_ALIAS = "stream_id_alias";
|
||||
public static final String STREAM_PROGRESS_TIME = "progress_time";
|
||||
public static final String STREAM_PROGRESS_MILLIS = "progress_time";
|
||||
|
||||
/**
|
||||
* Playback state will not be saved, if playback time is less than this threshold.
|
||||
* Playback state will not be saved, if playback time is less than this threshold (5000ms = 5s).
|
||||
*/
|
||||
private static final int PLAYBACK_SAVE_THRESHOLD_START_SECONDS = 5;
|
||||
private static final long PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS = 5000;
|
||||
|
||||
/**
|
||||
* Playback state will not be saved, if time left is less than this threshold.
|
||||
* Stream will be considered finished if the playback time left exceeds this threshold
|
||||
* (60000ms = 60s).
|
||||
* @see #isFinished(long)
|
||||
* @see org.schabi.newpipe.database.feed.dao.FeedDAO#getLiveOrNotPlayedStreams()
|
||||
* @see org.schabi.newpipe.database.feed.dao.FeedDAO#getLiveOrNotPlayedStreamsForGroup(long)
|
||||
*/
|
||||
private static final int PLAYBACK_SAVE_THRESHOLD_END_SECONDS = 10;
|
||||
public static final long PLAYBACK_FINISHED_END_MILLISECONDS = 60000;
|
||||
|
||||
@ColumnInfo(name = JOIN_STREAM_ID)
|
||||
private long streamUid;
|
||||
|
||||
@ColumnInfo(name = STREAM_PROGRESS_TIME)
|
||||
private long progressTime;
|
||||
@ColumnInfo(name = STREAM_PROGRESS_MILLIS)
|
||||
private long progressMillis;
|
||||
|
||||
public StreamStateEntity(final long streamUid, final long progressTime) {
|
||||
public StreamStateEntity(final long streamUid, final long progressMillis) {
|
||||
this.streamUid = streamUid;
|
||||
this.progressTime = progressTime;
|
||||
this.progressMillis = progressMillis;
|
||||
}
|
||||
|
||||
public long getStreamUid() {
|
||||
@ -55,27 +60,53 @@ public class StreamStateEntity {
|
||||
this.streamUid = streamUid;
|
||||
}
|
||||
|
||||
public long getProgressTime() {
|
||||
return progressTime;
|
||||
public long getProgressMillis() {
|
||||
return progressMillis;
|
||||
}
|
||||
|
||||
public void setProgressTime(final long progressTime) {
|
||||
this.progressTime = progressTime;
|
||||
public void setProgressMillis(final long progressMillis) {
|
||||
this.progressMillis = progressMillis;
|
||||
}
|
||||
|
||||
public boolean isValid(final int durationInSeconds) {
|
||||
final int seconds = (int) TimeUnit.MILLISECONDS.toSeconds(progressTime);
|
||||
return seconds > PLAYBACK_SAVE_THRESHOLD_START_SECONDS
|
||||
&& seconds < durationInSeconds - PLAYBACK_SAVE_THRESHOLD_END_SECONDS;
|
||||
/**
|
||||
* The state will be considered valid, and thus be saved, if the progress is more than {@link
|
||||
* #PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS} or at least 1/4 of the video length.
|
||||
* @param durationInSeconds the duration of the stream connected with this state, in seconds
|
||||
* @return whether this stream state entity should be saved or not
|
||||
*/
|
||||
public boolean isValid(final long durationInSeconds) {
|
||||
return progressMillis > PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS
|
||||
|| progressMillis > durationInSeconds * 1000 / 4;
|
||||
}
|
||||
|
||||
/**
|
||||
* The video will be considered as finished, if the time left is less than {@link
|
||||
* #PLAYBACK_FINISHED_END_MILLISECONDS} and the progress is at least 3/4 of the video length.
|
||||
* The state will be saved anyway, so that it can be shown under stream info items, but the
|
||||
* player will not resume if a state is considered as finished. Finished streams are also the
|
||||
* ones that can be filtered out in the feed fragment.
|
||||
* @see org.schabi.newpipe.database.feed.dao.FeedDAO#getLiveOrNotPlayedStreams()
|
||||
* @see org.schabi.newpipe.database.feed.dao.FeedDAO#getLiveOrNotPlayedStreamsForGroup(long)
|
||||
* @param durationInSeconds the duration of the stream connected with this state, in seconds
|
||||
* @return whether the stream is finished or not
|
||||
*/
|
||||
public boolean isFinished(final long durationInSeconds) {
|
||||
return progressMillis >= durationInSeconds * 1000 - PLAYBACK_FINISHED_END_MILLISECONDS
|
||||
&& progressMillis >= durationInSeconds * 1000 * 3 / 4;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(@Nullable final Object obj) {
|
||||
if (obj instanceof StreamStateEntity) {
|
||||
return ((StreamStateEntity) obj).streamUid == streamUid
|
||||
&& ((StreamStateEntity) obj).progressTime == progressTime;
|
||||
&& ((StreamStateEntity) obj).progressMillis == progressMillis;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(streamUid, progressMillis);
|
||||
}
|
||||
}
|
||||
|
@ -49,6 +49,8 @@ import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||
import org.schabi.newpipe.extractor.stream.SubtitlesStream;
|
||||
import org.schabi.newpipe.extractor.stream.VideoStream;
|
||||
import org.schabi.newpipe.settings.NewPipeSettings;
|
||||
import org.schabi.newpipe.streams.io.StoredDirectoryHelper;
|
||||
import org.schabi.newpipe.streams.io.StoredFileHelper;
|
||||
import org.schabi.newpipe.util.FilePickerActivityHelper;
|
||||
import org.schabi.newpipe.util.FilenameUtils;
|
||||
import org.schabi.newpipe.util.ListHelper;
|
||||
@ -68,8 +70,6 @@ import icepick.Icepick;
|
||||
import icepick.State;
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||
import us.shandian.giga.get.MissionRecoveryInfo;
|
||||
import us.shandian.giga.io.StoredDirectoryHelper;
|
||||
import us.shandian.giga.io.StoredFileHelper;
|
||||
import us.shandian.giga.postprocessing.Postprocessing;
|
||||
import us.shandian.giga.service.DownloadManager;
|
||||
import us.shandian.giga.service.DownloadManagerService;
|
||||
@ -83,6 +83,8 @@ public class DownloadDialog extends DialogFragment
|
||||
private static final String TAG = "DialogFragment";
|
||||
private static final boolean DEBUG = MainActivity.DEBUG;
|
||||
private static final int REQUEST_DOWNLOAD_SAVE_AS = 0x1230;
|
||||
private static final int REQUEST_DOWNLOAD_PICK_VIDEO_FOLDER = 0x789E;
|
||||
private static final int REQUEST_DOWNLOAD_PICK_AUDIO_FOLDER = 0x789F;
|
||||
|
||||
@State
|
||||
StreamInfo currentInfo;
|
||||
@ -116,6 +118,10 @@ public class DownloadDialog extends DialogFragment
|
||||
|
||||
private SharedPreferences prefs;
|
||||
|
||||
// Variables for file name and MIME type when picking new folder because it's not set yet
|
||||
private String filenameTmp;
|
||||
private String mimeTmp;
|
||||
|
||||
public static DownloadDialog newInstance(final StreamInfo info) {
|
||||
final DownloadDialog dialog = new DownloadDialog();
|
||||
dialog.setInfo(info);
|
||||
@ -153,10 +159,6 @@ public class DownloadDialog extends DialogFragment
|
||||
setVideoStreams(new StreamSizeWrapper<>(videoStreams, getContext()));
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// LifeCycle
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
public void setVideoStreams(final StreamSizeWrapper<VideoStream> wvs) {
|
||||
this.wrappedVideoStreams = wvs;
|
||||
}
|
||||
@ -374,12 +376,16 @@ public class DownloadDialog extends DialogFragment
|
||||
public void onActivityResult(final int requestCode, final int resultCode, final Intent data) {
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
|
||||
if (requestCode == REQUEST_DOWNLOAD_SAVE_AS && resultCode == Activity.RESULT_OK) {
|
||||
if (data.getData() == null) {
|
||||
showFailedDialog(R.string.general_error);
|
||||
return;
|
||||
}
|
||||
if (resultCode != Activity.RESULT_OK) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.getData() == null) {
|
||||
showFailedDialog(R.string.general_error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestCode == REQUEST_DOWNLOAD_SAVE_AS) {
|
||||
if (FilePickerActivityHelper.isOwnFileUri(context, data.getData())) {
|
||||
final File file = Utils.getFileForUri(data.getData());
|
||||
checkSelectedDownload(null, Uri.fromFile(file), file.getName(),
|
||||
@ -396,6 +402,37 @@ public class DownloadDialog extends DialogFragment
|
||||
// check if the selected file was previously used
|
||||
checkSelectedDownload(null, data.getData(), docFile.getName(),
|
||||
docFile.getType());
|
||||
} else if (requestCode == REQUEST_DOWNLOAD_PICK_AUDIO_FOLDER
|
||||
|| requestCode == REQUEST_DOWNLOAD_PICK_VIDEO_FOLDER) {
|
||||
Uri uri = data.getData();
|
||||
if (FilePickerActivityHelper.isOwnFileUri(context, uri)) {
|
||||
uri = Uri.fromFile(Utils.getFileForUri(uri));
|
||||
} else {
|
||||
context.grantUriPermission(context.getPackageName(), uri,
|
||||
StoredDirectoryHelper.PERMISSION_FLAGS);
|
||||
}
|
||||
|
||||
final String key;
|
||||
final String tag;
|
||||
if (requestCode == REQUEST_DOWNLOAD_PICK_AUDIO_FOLDER) {
|
||||
key = getString(R.string.download_path_audio_key);
|
||||
tag = DownloadManager.TAG_AUDIO;
|
||||
} else {
|
||||
key = getString(R.string.download_path_video_key);
|
||||
tag = DownloadManager.TAG_VIDEO;
|
||||
}
|
||||
|
||||
PreferenceManager.getDefaultSharedPreferences(context).edit()
|
||||
.putString(key, uri.toString()).apply();
|
||||
|
||||
try {
|
||||
final StoredDirectoryHelper mainStorage
|
||||
= new StoredDirectoryHelper(context, uri, tag);
|
||||
checkSelectedDownload(mainStorage, mainStorage.findFile(filenameTmp),
|
||||
filenameTmp, mimeTmp);
|
||||
} catch (final IOException e) {
|
||||
showFailedDialog(R.string.general_error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -603,84 +640,92 @@ public class DownloadDialog extends DialogFragment
|
||||
private void prepareSelectedDownload() {
|
||||
final StoredDirectoryHelper mainStorage;
|
||||
final MediaFormat format;
|
||||
final String mime;
|
||||
final String selectedMediaType;
|
||||
|
||||
// first, build the filename and get the output folder (if possible)
|
||||
// later, run a very very very large file checking logic
|
||||
|
||||
String filename = getNameEditText().concat(".");
|
||||
filenameTmp = getNameEditText().concat(".");
|
||||
|
||||
switch (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()) {
|
||||
case R.id.audio_button:
|
||||
selectedMediaType = getString(R.string.last_download_type_audio_key);
|
||||
mainStorage = mainStorageAudio;
|
||||
format = audioStreamsAdapter.getItem(selectedAudioIndex).getFormat();
|
||||
switch (format) {
|
||||
case WEBMA_OPUS:
|
||||
mime = "audio/ogg";
|
||||
filename += "opus";
|
||||
break;
|
||||
default:
|
||||
mime = format.mimeType;
|
||||
filename += format.suffix;
|
||||
break;
|
||||
if (format == MediaFormat.WEBMA_OPUS) {
|
||||
mimeTmp = "audio/ogg";
|
||||
filenameTmp += "opus";
|
||||
} else {
|
||||
mimeTmp = format.mimeType;
|
||||
filenameTmp += format.suffix;
|
||||
}
|
||||
break;
|
||||
case R.id.video_button:
|
||||
selectedMediaType = getString(R.string.last_download_type_video_key);
|
||||
mainStorage = mainStorageVideo;
|
||||
format = videoStreamsAdapter.getItem(selectedVideoIndex).getFormat();
|
||||
mime = format.mimeType;
|
||||
filename += format.suffix;
|
||||
mimeTmp = format.mimeType;
|
||||
filenameTmp += format.suffix;
|
||||
break;
|
||||
case R.id.subtitle_button:
|
||||
selectedMediaType = getString(R.string.last_download_type_subtitle_key);
|
||||
mainStorage = mainStorageVideo; // subtitle & video files go together
|
||||
format = subtitleStreamsAdapter.getItem(selectedSubtitleIndex).getFormat();
|
||||
mime = format.mimeType;
|
||||
filename += (format == MediaFormat.TTML ? MediaFormat.SRT : format).suffix;
|
||||
mimeTmp = format.mimeType;
|
||||
filenameTmp += (format == MediaFormat.TTML ? MediaFormat.SRT : format).suffix;
|
||||
break;
|
||||
default:
|
||||
throw new RuntimeException("No stream selected");
|
||||
}
|
||||
|
||||
if (mainStorage == null || askForSavePath) {
|
||||
// This part is called if with SAF preferred:
|
||||
// * older android version running
|
||||
// * save path not defined (via download settings)
|
||||
// * the user checked the "ask where to download" option
|
||||
if (!askForSavePath
|
||||
&& (mainStorage == null
|
||||
|| mainStorage.isDirect() == NewPipeSettings.useStorageAccessFramework(context)
|
||||
|| mainStorage.isInvalidSafStorage())) {
|
||||
// Pick new download folder if one of:
|
||||
// - Download folder is not set
|
||||
// - Download folder uses SAF while SAF is disabled
|
||||
// - Download folder doesn't use SAF while SAF is enabled
|
||||
// - Download folder uses SAF but the user manually revoked access to it
|
||||
Toast.makeText(context, getString(R.string.no_dir_yet),
|
||||
Toast.LENGTH_LONG).show();
|
||||
|
||||
if (!askForSavePath) {
|
||||
Toast.makeText(context, getString(R.string.no_available_dir),
|
||||
Toast.LENGTH_LONG).show();
|
||||
}
|
||||
|
||||
if (NewPipeSettings.useStorageAccessFramework(context)) {
|
||||
StoredFileHelper.requestSafWithFileCreation(this, REQUEST_DOWNLOAD_SAVE_AS,
|
||||
filename, mime);
|
||||
if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() == R.id.audio_button) {
|
||||
startActivityForResult(StoredDirectoryHelper.getPicker(context),
|
||||
REQUEST_DOWNLOAD_PICK_AUDIO_FOLDER);
|
||||
} else {
|
||||
File initialSavePath;
|
||||
if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() == R.id.audio_button) {
|
||||
initialSavePath = NewPipeSettings.getDir(Environment.DIRECTORY_MUSIC);
|
||||
} else {
|
||||
initialSavePath = NewPipeSettings.getDir(Environment.DIRECTORY_MOVIES);
|
||||
}
|
||||
|
||||
initialSavePath = new File(initialSavePath, filename);
|
||||
startActivityForResult(FilePickerActivityHelper.chooseFileToSave(context,
|
||||
initialSavePath.getAbsolutePath()), REQUEST_DOWNLOAD_SAVE_AS);
|
||||
startActivityForResult(StoredDirectoryHelper.getPicker(context),
|
||||
REQUEST_DOWNLOAD_PICK_VIDEO_FOLDER);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (askForSavePath) {
|
||||
final Uri initialPath;
|
||||
if (NewPipeSettings.useStorageAccessFramework(context)) {
|
||||
initialPath = null;
|
||||
} else {
|
||||
final File initialSavePath;
|
||||
if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() == R.id.audio_button) {
|
||||
initialSavePath = NewPipeSettings.getDir(Environment.DIRECTORY_MUSIC);
|
||||
} else {
|
||||
initialSavePath = NewPipeSettings.getDir(Environment.DIRECTORY_MOVIES);
|
||||
}
|
||||
initialPath = Uri.parse(initialSavePath.getAbsolutePath());
|
||||
}
|
||||
|
||||
startActivityForResult(StoredFileHelper.getNewPicker(context,
|
||||
filenameTmp, mimeTmp, initialPath), REQUEST_DOWNLOAD_SAVE_AS);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// check for existing file with the same name
|
||||
checkSelectedDownload(mainStorage, mainStorage.findFile(filename), filename, mime);
|
||||
checkSelectedDownload(mainStorage, mainStorage.findFile(filenameTmp), filenameTmp, mimeTmp);
|
||||
|
||||
// remember the last media type downloaded by the user
|
||||
prefs.edit()
|
||||
.putString(getString(R.string.last_used_download_type), selectedMediaType)
|
||||
prefs.edit().putString(getString(R.string.last_used_download_type), selectedMediaType)
|
||||
.apply();
|
||||
}
|
||||
|
||||
@ -708,15 +753,14 @@ public class DownloadDialog extends DialogFragment
|
||||
return;
|
||||
}
|
||||
|
||||
// check if is our file
|
||||
// get state of potential mission referring to the same file
|
||||
final MissionState state = downloadManager.checkForExistingMission(storage);
|
||||
@StringRes
|
||||
final int msgBtn;
|
||||
@StringRes
|
||||
final int msgBody;
|
||||
@StringRes final int msgBtn;
|
||||
@StringRes final int msgBody;
|
||||
|
||||
// this switch checks if there is already a mission referring to the same file
|
||||
switch (state) {
|
||||
case Finished:
|
||||
case Finished: // there is already a finished mission
|
||||
msgBtn = R.string.overwrite;
|
||||
msgBody = R.string.overwrite_finished_warning;
|
||||
break;
|
||||
@ -728,7 +772,7 @@ public class DownloadDialog extends DialogFragment
|
||||
msgBtn = R.string.generate_unique_name;
|
||||
msgBody = R.string.download_already_running;
|
||||
break;
|
||||
case None:
|
||||
case None: // there is no mission referring to the same file
|
||||
if (mainStorage == null) {
|
||||
// This part is called if:
|
||||
// * using SAF on older android version
|
||||
@ -763,7 +807,7 @@ public class DownloadDialog extends DialogFragment
|
||||
msgBody = R.string.overwrite_unrelated_warning;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
return; // unreachable
|
||||
}
|
||||
|
||||
final AlertDialog.Builder askDialog = new AlertDialog.Builder(context)
|
||||
|
@ -27,7 +27,7 @@ import org.schabi.newpipe.MainActivity;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.databinding.ActivityErrorBinding;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
import org.schabi.newpipe.util.ShareUtils;
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
@ -195,7 +195,8 @@ public class ErrorActivity extends AppCompatActivity {
|
||||
onBackPressed();
|
||||
return true;
|
||||
case R.id.menu_item_share_error:
|
||||
ShareUtils.shareText(this, getString(R.string.error_report_title), buildJson());
|
||||
ShareUtils.shareText(getApplicationContext(),
|
||||
getString(R.string.error_report_title), buildJson());
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
@ -220,13 +221,10 @@ public class ErrorActivity extends AppCompatActivity {
|
||||
+ getString(R.string.app_name) + " "
|
||||
+ BuildConfig.VERSION_NAME)
|
||||
.putExtra(Intent.EXTRA_TEXT, buildJson());
|
||||
if (i.resolveActivity(getPackageManager()) != null) {
|
||||
ShareUtils.openIntentInApp(context, i);
|
||||
}
|
||||
ShareUtils.openIntentInApp(context, i, true);
|
||||
} else if (action.equals("GITHUB")) { // open the NewPipe issue page on GitHub
|
||||
ShareUtils.openUrlInBrowser(this, ERROR_GITHUB_ISSUE_URL, false);
|
||||
}
|
||||
|
||||
})
|
||||
.setNegativeButton(R.string.decline, (dialog, which) -> {
|
||||
// do nothing
|
||||
|
@ -6,6 +6,7 @@ import kotlinx.android.parcel.Parcelize
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.extractor.Info
|
||||
import org.schabi.newpipe.extractor.NewPipe
|
||||
import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException
|
||||
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
|
||||
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException
|
||||
import org.schabi.newpipe.extractor.exceptions.ExtractionException
|
||||
@ -95,6 +96,7 @@ class ErrorInfo(
|
||||
action: UserAction
|
||||
): Int {
|
||||
return when {
|
||||
throwable is AccountTerminatedException -> R.string.account_terminated
|
||||
throwable is ContentNotAvailableException -> R.string.content_not_available
|
||||
throwable != null && throwable.isNetworkRelated -> R.string.network_error
|
||||
throwable is ContentNotSupportedException -> R.string.content_not_supported
|
||||
|
@ -13,6 +13,8 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import org.schabi.newpipe.MainActivity
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.extractor.NewPipe
|
||||
import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException
|
||||
import org.schabi.newpipe.extractor.exceptions.AgeRestrictedContentException
|
||||
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
|
||||
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException
|
||||
@ -22,9 +24,11 @@ import org.schabi.newpipe.extractor.exceptions.PrivateContentException
|
||||
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
|
||||
import org.schabi.newpipe.extractor.exceptions.SoundCloudGoPlusContentException
|
||||
import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentException
|
||||
import org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty
|
||||
import org.schabi.newpipe.ktx.animate
|
||||
import org.schabi.newpipe.ktx.isInterruptedCaused
|
||||
import org.schabi.newpipe.ktx.isNetworkRelated
|
||||
import org.schabi.newpipe.util.ServiceHelper
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class ErrorPanelHelper(
|
||||
@ -35,6 +39,8 @@ class ErrorPanelHelper(
|
||||
private val context: Context = rootView.context!!
|
||||
private val errorPanelRoot: View = rootView.findViewById(R.id.error_panel)
|
||||
private val errorTextView: TextView = errorPanelRoot.findViewById(R.id.error_message_view)
|
||||
private val errorServiceInfoTextView: TextView = errorPanelRoot.findViewById(R.id.error_message_service_info_view)
|
||||
private val errorServiceExplenationTextView: TextView = errorPanelRoot.findViewById(R.id.error_message_service_explenation_view)
|
||||
private val errorButtonAction: Button = errorPanelRoot.findViewById(R.id.error_button_action)
|
||||
private val errorButtonRetry: Button = errorPanelRoot.findViewById(R.id.error_button_retry)
|
||||
|
||||
@ -70,13 +76,40 @@ class ErrorPanelHelper(
|
||||
errorButtonAction.setOnClickListener(null)
|
||||
}
|
||||
errorTextView.setText(R.string.recaptcha_request_toast)
|
||||
// additional info is only provided by AccountTerminatedException
|
||||
errorServiceInfoTextView.isVisible = false
|
||||
errorServiceExplenationTextView.isVisible = false
|
||||
errorButtonRetry.isVisible = true
|
||||
} else if (errorInfo.throwable is AccountTerminatedException) {
|
||||
errorButtonRetry.isVisible = false
|
||||
errorButtonAction.isVisible = false
|
||||
errorTextView.setText(R.string.account_terminated)
|
||||
if (!isNullOrEmpty((errorInfo.throwable as AccountTerminatedException).message)) {
|
||||
errorServiceInfoTextView.setText(
|
||||
context.resources.getString(
|
||||
R.string.service_provides_reason,
|
||||
NewPipe.getNameOfService(ServiceHelper.getSelectedServiceId(context))
|
||||
)
|
||||
)
|
||||
errorServiceExplenationTextView.setText(
|
||||
(errorInfo.throwable as AccountTerminatedException).message
|
||||
)
|
||||
errorServiceInfoTextView.isVisible = true
|
||||
errorServiceExplenationTextView.isVisible = true
|
||||
} else {
|
||||
errorServiceInfoTextView.isVisible = false
|
||||
errorServiceExplenationTextView.isVisible = false
|
||||
}
|
||||
} else {
|
||||
errorButtonAction.setText(R.string.error_snackbar_action)
|
||||
errorButtonAction.setOnClickListener {
|
||||
ErrorActivity.reportError(context, errorInfo)
|
||||
}
|
||||
|
||||
// additional info is only provided by AccountTerminatedException
|
||||
errorServiceInfoTextView.isVisible = false
|
||||
errorServiceExplenationTextView.isVisible = false
|
||||
|
||||
// hide retry button by default, then show only if not unavailable/unsupported content
|
||||
errorButtonRetry.isVisible = false
|
||||
errorTextView.setText(
|
||||
|
@ -130,7 +130,7 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte
|
||||
Log.d(TAG, "onCreateOptionsMenu() called with: "
|
||||
+ "menu = [" + menu + "], inflater = [" + inflater + "]");
|
||||
}
|
||||
inflater.inflate(R.menu.main_fragment_menu, menu);
|
||||
inflater.inflate(R.menu.menu_main_fragment, menu);
|
||||
|
||||
final ActionBar supportActionBar = activity.getSupportActionBar();
|
||||
if (supportActionBar != null) {
|
||||
|
@ -23,15 +23,15 @@ import org.schabi.newpipe.extractor.stream.Description;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.ShareUtils;
|
||||
import org.schabi.newpipe.util.TextLinkifier;
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||
import org.schabi.newpipe.util.external_communication.TextLinkifier;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import icepick.State;
|
||||
import io.reactivex.rxjava3.disposables.Disposable;
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||
|
||||
import static android.text.TextUtils.isEmpty;
|
||||
import static org.schabi.newpipe.extractor.stream.StreamExtractor.NO_AGE_LIMIT;
|
||||
@ -41,8 +41,7 @@ public class DescriptionFragment extends BaseFragment {
|
||||
|
||||
@State
|
||||
StreamInfo streamInfo = null;
|
||||
@Nullable
|
||||
Disposable descriptionDisposable = null;
|
||||
final CompositeDisposable descriptionDisposables = new CompositeDisposable();
|
||||
FragmentDescriptionBinding binding;
|
||||
|
||||
public DescriptionFragment() {
|
||||
@ -67,10 +66,8 @@ public class DescriptionFragment extends BaseFragment {
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
descriptionDisposables.clear();
|
||||
super.onDestroy();
|
||||
if (descriptionDisposable != null) {
|
||||
descriptionDisposable.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -133,17 +130,17 @@ public class DescriptionFragment extends BaseFragment {
|
||||
final Description description = streamInfo.getDescription();
|
||||
switch (description.getType()) {
|
||||
case Description.HTML:
|
||||
descriptionDisposable = TextLinkifier.createLinksFromHtmlBlock(requireContext(),
|
||||
description.getContent(), binding.detailDescriptionView,
|
||||
HtmlCompat.FROM_HTML_MODE_LEGACY);
|
||||
TextLinkifier.createLinksFromHtmlBlock(binding.detailDescriptionView,
|
||||
description.getContent(), HtmlCompat.FROM_HTML_MODE_LEGACY, streamInfo,
|
||||
descriptionDisposables);
|
||||
break;
|
||||
case Description.MARKDOWN:
|
||||
descriptionDisposable = TextLinkifier.createLinksFromMarkdownText(requireContext(),
|
||||
description.getContent(), binding.detailDescriptionView);
|
||||
TextLinkifier.createLinksFromMarkdownText(binding.detailDescriptionView,
|
||||
description.getContent(), streamInfo, descriptionDisposables);
|
||||
break;
|
||||
case Description.PLAIN_TEXT: default:
|
||||
descriptionDisposable = TextLinkifier.createLinksFromPlainText(requireContext(),
|
||||
description.getContent(), binding.detailDescriptionView);
|
||||
TextLinkifier.createLinksFromPlainText(binding.detailDescriptionView,
|
||||
description.getContent(), streamInfo, descriptionDisposables);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -198,8 +195,8 @@ public class DescriptionFragment extends BaseFragment {
|
||||
});
|
||||
|
||||
if (linkifyContent) {
|
||||
TextLinkifier.createLinksFromPlainText(requireContext(),
|
||||
content, itemBinding.metadataContentView);
|
||||
TextLinkifier.createLinksFromPlainText(itemBinding.metadataContentView, content, null,
|
||||
descriptionDisposables);
|
||||
} else {
|
||||
itemBinding.metadataContentView.setText(content);
|
||||
}
|
||||
|
@ -91,12 +91,12 @@ import org.schabi.newpipe.util.Constants;
|
||||
import org.schabi.newpipe.util.DeviceUtils;
|
||||
import org.schabi.newpipe.util.ExtractorHelper;
|
||||
import org.schabi.newpipe.util.ImageDisplayConstants;
|
||||
import org.schabi.newpipe.util.KoreUtil;
|
||||
import org.schabi.newpipe.util.external_communication.KoreUtils;
|
||||
import org.schabi.newpipe.util.ListHelper;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.PermissionHelper;
|
||||
import org.schabi.newpipe.util.ShareUtils;
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
|
||||
import java.util.ArrayList;
|
||||
@ -454,8 +454,8 @@ public final class VideoDetailFragment
|
||||
break;
|
||||
case R.id.detail_controls_share:
|
||||
if (currentInfo != null) {
|
||||
ShareUtils.shareText(requireContext(),
|
||||
currentInfo.getName(), currentInfo.getUrl());
|
||||
ShareUtils.shareText(requireContext(), currentInfo.getName(),
|
||||
currentInfo.getUrl(), currentInfo.getThumbnailUrl());
|
||||
}
|
||||
break;
|
||||
case R.id.detail_controls_open_in_browser:
|
||||
@ -472,7 +472,7 @@ public final class VideoDetailFragment
|
||||
if (DEBUG) {
|
||||
Log.i(TAG, "Failed to start kore", e);
|
||||
}
|
||||
KoreUtil.showInstallKoreDialog(requireContext());
|
||||
KoreUtils.showInstallKoreDialog(requireContext());
|
||||
}
|
||||
}
|
||||
break;
|
||||
@ -631,7 +631,7 @@ public final class VideoDetailFragment
|
||||
binding.detailControlsShare.setOnClickListener(this);
|
||||
binding.detailControlsOpenInBrowser.setOnClickListener(this);
|
||||
binding.detailControlsPlayWithKodi.setOnClickListener(this);
|
||||
binding.detailControlsPlayWithKodi.setVisibility(KoreUtil.shouldShowPlayWithKodi(
|
||||
binding.detailControlsPlayWithKodi.setVisibility(KoreUtils.shouldShowPlayWithKodi(
|
||||
requireContext(), serviceId) ? View.VISIBLE : View.GONE);
|
||||
|
||||
binding.overlayThumbnail.setOnClickListener(this);
|
||||
@ -1546,8 +1546,8 @@ public final class VideoDetailFragment
|
||||
.getDefaultResolutionIndex(activity, sortedVideoStreams);
|
||||
updateProgressInfo(info);
|
||||
initThumbnailViews(info);
|
||||
disposables.add(showMetaInfoInTextView(info.getMetaInfo(), binding.detailMetaInfoTextView,
|
||||
binding.detailMetaInfoSeparator));
|
||||
showMetaInfoInTextView(info.getMetaInfo(), binding.detailMetaInfoTextView,
|
||||
binding.detailMetaInfoSeparator, disposables);
|
||||
|
||||
if (player == null || player.isStopped()) {
|
||||
updateOverlayData(info.getName(), info.getUploaderName(), info.getThumbnailUrl());
|
||||
@ -1669,7 +1669,7 @@ public final class VideoDetailFragment
|
||||
.onErrorComplete()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(state -> {
|
||||
showPlaybackProgress(state.getProgressTime(), info.getDuration() * 1000);
|
||||
showPlaybackProgress(state.getProgressMillis(), info.getDuration() * 1000);
|
||||
animate(binding.positionView, true, 500);
|
||||
animate(binding.detailPositionView, true, 500);
|
||||
}, e -> {
|
||||
|
@ -33,7 +33,7 @@ import org.schabi.newpipe.fragments.OnScrollBelowItemsListener;
|
||||
import org.schabi.newpipe.info_list.InfoItemDialog;
|
||||
import org.schabi.newpipe.info_list.InfoListAdapter;
|
||||
import org.schabi.newpipe.player.helper.PlayerHolder;
|
||||
import org.schabi.newpipe.util.KoreUtil;
|
||||
import org.schabi.newpipe.util.external_communication.KoreUtils;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.OnClickGesture;
|
||||
import org.schabi.newpipe.util.StateSaver;
|
||||
@ -371,7 +371,7 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
|
||||
));
|
||||
}
|
||||
entries.add(StreamDialogEntry.open_in_browser);
|
||||
if (KoreUtil.shouldShowPlayWithKodi(context, item.getServiceId())) {
|
||||
if (KoreUtils.shouldShowPlayWithKodi(context, item.getServiceId())) {
|
||||
entries.add(StreamDialogEntry.play_with_kodi);
|
||||
}
|
||||
if (!isNullOrEmpty(item.getUploaderUrl())) {
|
||||
@ -389,7 +389,8 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) {
|
||||
public void onCreateOptionsMenu(@NonNull final Menu menu,
|
||||
@NonNull final MenuInflater inflater) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onCreateOptionsMenu() called with: "
|
||||
+ "menu = [" + menu + "], inflater = [" + inflater + "]");
|
||||
|
@ -43,7 +43,7 @@ import org.schabi.newpipe.util.ExtractorHelper;
|
||||
import org.schabi.newpipe.util.ImageDisplayConstants;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.ShareUtils;
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
|
||||
import java.util.ArrayList;
|
||||
@ -164,7 +164,8 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) {
|
||||
public void onCreateOptionsMenu(@NonNull final Menu menu,
|
||||
@NonNull final MenuInflater inflater) {
|
||||
super.onCreateOptionsMenu(menu, inflater);
|
||||
final ActionBar supportActionBar = activity.getSupportActionBar();
|
||||
if (useAsFrontPage && supportActionBar != null) {
|
||||
@ -203,7 +204,8 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
||||
break;
|
||||
case R.id.menu_item_share:
|
||||
if (currentInfo != null) {
|
||||
ShareUtils.shareText(requireContext(), name, currentInfo.getOriginalUrl());
|
||||
ShareUtils.shareText(requireContext(), name, currentInfo.getOriginalUrl(),
|
||||
currentInfo.getAvatarUrl());
|
||||
}
|
||||
break;
|
||||
default:
|
||||
|
@ -85,7 +85,8 @@ public class CommentsFragment extends BaseListInfoFragment<CommentsInfo> {
|
||||
public void setTitle(final String title) { }
|
||||
|
||||
@Override
|
||||
public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { }
|
||||
public void onCreateOptionsMenu(@NonNull final Menu menu,
|
||||
@NonNull final MenuInflater inflater) { }
|
||||
|
||||
@Override
|
||||
protected boolean isGridLayout() {
|
||||
|
@ -131,7 +131,8 @@ public class KioskFragment extends BaseListInfoFragment<KioskInfo> {
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) {
|
||||
public void onCreateOptionsMenu(@NonNull final Menu menu,
|
||||
@NonNull final MenuInflater inflater) {
|
||||
super.onCreateOptionsMenu(menu, inflater);
|
||||
final ActionBar supportActionBar = activity.getSupportActionBar();
|
||||
if (supportActionBar != null && useAsFrontPage) {
|
||||
|
@ -42,10 +42,10 @@ import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||
import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue;
|
||||
import org.schabi.newpipe.util.ExtractorHelper;
|
||||
import org.schabi.newpipe.util.ImageDisplayConstants;
|
||||
import org.schabi.newpipe.util.KoreUtil;
|
||||
import org.schabi.newpipe.util.external_communication.KoreUtils;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.ShareUtils;
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||
import org.schabi.newpipe.util.StreamDialogEntry;
|
||||
|
||||
import java.util.ArrayList;
|
||||
@ -162,7 +162,7 @@ public class PlaylistFragment extends BaseListInfoFragment<PlaylistInfo> {
|
||||
));
|
||||
}
|
||||
entries.add(StreamDialogEntry.open_in_browser);
|
||||
if (KoreUtil.shouldShowPlayWithKodi(context, item.getServiceId())) {
|
||||
if (KoreUtils.shouldShowPlayWithKodi(context, item.getServiceId())) {
|
||||
entries.add(StreamDialogEntry.play_with_kodi);
|
||||
}
|
||||
|
||||
@ -181,7 +181,8 @@ public class PlaylistFragment extends BaseListInfoFragment<PlaylistInfo> {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) {
|
||||
public void onCreateOptionsMenu(@NonNull final Menu menu,
|
||||
@NonNull final MenuInflater inflater) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onCreateOptionsMenu() called with: "
|
||||
+ "menu = [" + menu + "], inflater = [" + inflater + "]");
|
||||
@ -251,7 +252,7 @@ public class PlaylistFragment extends BaseListInfoFragment<PlaylistInfo> {
|
||||
ShareUtils.openUrlInBrowser(requireContext(), url);
|
||||
break;
|
||||
case R.id.menu_item_share:
|
||||
ShareUtils.shareText(requireContext(), name, url);
|
||||
ShareUtils.shareText(requireContext(), name, url, currentInfo.getThumbnailUrl());
|
||||
break;
|
||||
case R.id.menu_item_bookmark:
|
||||
onBookmarkClicked();
|
||||
|
@ -227,6 +227,25 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
||||
initSearchListeners();
|
||||
}
|
||||
|
||||
private void updateService() {
|
||||
try {
|
||||
service = NewPipe.getService(serviceId);
|
||||
} catch (final Exception e) {
|
||||
ErrorActivity.reportUiErrorInSnackbar(this,
|
||||
"Getting service for id " + serviceId, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStart() {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onStart() called");
|
||||
}
|
||||
super.onStart();
|
||||
|
||||
updateService();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPause() {
|
||||
super.onPause();
|
||||
@ -250,13 +269,6 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
||||
}
|
||||
super.onResume();
|
||||
|
||||
try {
|
||||
service = NewPipe.getService(serviceId);
|
||||
} catch (final Exception e) {
|
||||
ErrorActivity.reportUiErrorInSnackbar(this,
|
||||
"Getting service for id " + serviceId, e);
|
||||
}
|
||||
|
||||
if (suggestionDisposable == null || suggestionDisposable.isDisposed()) {
|
||||
initSuggestionObserver();
|
||||
}
|
||||
@ -278,8 +290,9 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
||||
|
||||
handleSearchSuggestion();
|
||||
|
||||
disposables.add(showMetaInfoInTextView(metaInfo == null ? null : Arrays.asList(metaInfo),
|
||||
searchBinding.searchMetaInfoTextView, searchBinding.searchMetaInfoSeparator));
|
||||
showMetaInfoInTextView(metaInfo == null ? null : Arrays.asList(metaInfo),
|
||||
searchBinding.searchMetaInfoTextView, searchBinding.searchMetaInfoSeparator,
|
||||
disposables);
|
||||
|
||||
if (TextUtils.isEmpty(searchString) || wasSearchFocused) {
|
||||
showKeyboardSearch();
|
||||
@ -412,7 +425,8 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) {
|
||||
public void onCreateOptionsMenu(@NonNull final Menu menu,
|
||||
@NonNull final MenuInflater inflater) {
|
||||
super.onCreateOptionsMenu(menu, inflater);
|
||||
|
||||
final ActionBar supportActionBar = activity.getSupportActionBar();
|
||||
@ -426,6 +440,12 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
||||
int itemId = 0;
|
||||
boolean isFirstItem = true;
|
||||
final Context c = getContext();
|
||||
|
||||
if (service == null) {
|
||||
Log.w(TAG, "onCreateOptionsMenu() called with null service");
|
||||
updateService();
|
||||
}
|
||||
|
||||
for (final String filter : service.getSearchQHFactory().getAvailableContentFilter()) {
|
||||
if (filter.equals(YoutubeSearchQueryHandlerFactory.MUSIC_SONGS)) {
|
||||
final MenuItem musicItem = menu.add(2,
|
||||
@ -841,7 +861,7 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
||||
infoListAdapter.clearStreamItemList();
|
||||
hideSuggestionsPanel();
|
||||
showMetaInfoInTextView(null, searchBinding.searchMetaInfoTextView,
|
||||
searchBinding.searchMetaInfoSeparator);
|
||||
searchBinding.searchMetaInfoSeparator, disposables);
|
||||
hideKeyboardSearch();
|
||||
|
||||
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
|
||||
metaInfo = new MetaInfo[result.getMetaInfo().size()];
|
||||
metaInfo = result.getMetaInfo().toArray(metaInfo);
|
||||
disposables.add(showMetaInfoInTextView(result.getMetaInfo(),
|
||||
searchBinding.searchMetaInfoTextView, searchBinding.searchMetaInfoSeparator));
|
||||
showMetaInfoInTextView(result.getMetaInfo(), searchBinding.searchMetaInfoTextView,
|
||||
searchBinding.searchMetaInfoSeparator, disposables);
|
||||
|
||||
handleSearchSuggestion();
|
||||
|
||||
|
@ -140,7 +140,8 @@ public class RelatedItemsFragment extends BaseListInfoFragment<RelatedItemInfo>
|
||||
}
|
||||
|
||||
@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) {
|
||||
|
@ -24,7 +24,7 @@ import org.schabi.newpipe.util.DeviceUtils;
|
||||
import org.schabi.newpipe.util.ImageDisplayConstants;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.ShareUtils;
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
@ -66,7 +66,7 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder {
|
||||
itemProgressView.setVisibility(View.VISIBLE);
|
||||
itemProgressView.setMax((int) item.getDuration());
|
||||
itemProgressView.setProgress((int) TimeUnit.MILLISECONDS
|
||||
.toSeconds(state2.getProgressTime()));
|
||||
.toSeconds(state2.getProgressMillis()));
|
||||
} else {
|
||||
itemProgressView.setVisibility(View.GONE);
|
||||
}
|
||||
@ -121,10 +121,10 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder {
|
||||
itemProgressView.setMax((int) item.getDuration());
|
||||
if (itemProgressView.getVisibility() == View.VISIBLE) {
|
||||
itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS
|
||||
.toSeconds(state.getProgressTime()));
|
||||
.toSeconds(state.getProgressMillis()));
|
||||
} else {
|
||||
itemProgressView.setProgress((int) TimeUnit.MILLISECONDS
|
||||
.toSeconds(state.getProgressTime()));
|
||||
.toSeconds(state.getProgressMillis()));
|
||||
ViewUtils.animate(itemProgressView, true, 500);
|
||||
}
|
||||
} else if (itemProgressView.getVisibility() == View.VISIBLE) {
|
||||
|
@ -9,6 +9,7 @@ import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.ActionBar;
|
||||
import androidx.fragment.app.Fragment;
|
||||
@ -145,7 +146,8 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) {
|
||||
public void onCreateOptionsMenu(@NonNull final Menu menu,
|
||||
@NonNull final MenuInflater inflater) {
|
||||
super.onCreateOptionsMenu(menu, inflater);
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onCreateOptionsMenu() called with: "
|
||||
|
@ -12,6 +12,7 @@ import org.schabi.newpipe.NewPipeDatabase
|
||||
import org.schabi.newpipe.database.feed.model.FeedEntity
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||
import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity
|
||||
import org.schabi.newpipe.database.stream.StreamWithState
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import org.schabi.newpipe.extractor.stream.StreamType
|
||||
@ -38,16 +39,19 @@ class FeedDatabaseManager(context: Context) {
|
||||
|
||||
fun database() = database
|
||||
|
||||
fun asStreamItems(groupId: Long = FeedGroupEntity.GROUP_ALL_ID): Flowable<List<StreamInfoItem>> {
|
||||
val streams = when (groupId) {
|
||||
FeedGroupEntity.GROUP_ALL_ID -> feedTable.getAllStreams()
|
||||
else -> feedTable.getAllStreamsFromGroup(groupId)
|
||||
}
|
||||
|
||||
return streams.map {
|
||||
val items = ArrayList<StreamInfoItem>(it.size)
|
||||
it.mapTo(items) { stream -> stream.toStreamInfoItem() }
|
||||
return@map items
|
||||
fun getStreams(
|
||||
groupId: Long = FeedGroupEntity.GROUP_ALL_ID,
|
||||
getPlayedStreams: Boolean = true
|
||||
): 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -60,8 +64,10 @@ class FeedDatabaseManager(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun outdatedSubscriptionsForGroup(groupId: Long = FeedGroupEntity.GROUP_ALL_ID, outdatedThreshold: OffsetDateTime) =
|
||||
feedTable.getAllOutdatedForGroup(groupId, outdatedThreshold)
|
||||
fun outdatedSubscriptionsForGroup(
|
||||
groupId: Long = FeedGroupEntity.GROUP_ALL_ID,
|
||||
outdatedThreshold: OffsetDateTime
|
||||
) = feedTable.getAllOutdatedForGroup(groupId, outdatedThreshold)
|
||||
|
||||
fun markAsOutdated(subscriptionId: Long) = feedTable
|
||||
.setLastUpdatedForSubscription(FeedLastUpdatedEntity(subscriptionId, null))
|
||||
@ -93,10 +99,7 @@ class FeedDatabaseManager(context: Context) {
|
||||
}
|
||||
|
||||
feedTable.setLastUpdatedForSubscription(
|
||||
FeedLastUpdatedEntity(
|
||||
subscriptionId,
|
||||
OffsetDateTime.now(ZoneOffset.UTC)
|
||||
)
|
||||
FeedLastUpdatedEntity(subscriptionId, OffsetDateTime.now(ZoneOffset.UTC))
|
||||
)
|
||||
}
|
||||
|
||||
@ -108,7 +111,12 @@ class FeedDatabaseManager(context: Context) {
|
||||
fun clear() {
|
||||
feedTable.deleteAll()
|
||||
val deletedOrphans = streamTable.deleteOrphans()
|
||||
if (DEBUG) Log.d(this::class.java.simpleName, "clear() → streamTable.deleteOrphans() → $deletedOrphans")
|
||||
if (DEBUG) {
|
||||
Log.d(
|
||||
this::class.java.simpleName,
|
||||
"clear() → streamTable.deleteOrphans() → $deletedOrphans"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// /////////////////////////////////////////////////////////////////////////
|
||||
@ -122,7 +130,8 @@ class FeedDatabaseManager(context: Context) {
|
||||
}
|
||||
|
||||
fun updateSubscriptionsForGroup(groupId: Long, subscriptionIds: List<Long>): Completable {
|
||||
return Completable.fromCallable { feedGroupTable.updateSubscriptionsForGroup(groupId, subscriptionIds) }
|
||||
return Completable
|
||||
.fromCallable { feedGroupTable.updateSubscriptionsForGroup(groupId, subscriptionIds) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
@ -19,7 +19,11 @@
|
||||
|
||||
package org.schabi.newpipe.local.feed
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import android.view.LayoutInflater
|
||||
@ -28,41 +32,74 @@ import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.Nullable
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.core.content.edit
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import com.xwray.groupie.GroupAdapter
|
||||
import com.xwray.groupie.GroupieViewHolder
|
||||
import com.xwray.groupie.Item
|
||||
import com.xwray.groupie.OnItemClickListener
|
||||
import com.xwray.groupie.OnItemLongClickListener
|
||||
import icepick.State
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.schabi.newpipe.NewPipeDatabase
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||
import org.schabi.newpipe.databinding.FragmentFeedBinding
|
||||
import org.schabi.newpipe.error.ErrorInfo
|
||||
import org.schabi.newpipe.error.UserAction
|
||||
import org.schabi.newpipe.fragments.list.BaseListFragment
|
||||
import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException
|
||||
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import org.schabi.newpipe.extractor.stream.StreamType
|
||||
import org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty
|
||||
import org.schabi.newpipe.fragments.BaseStateFragment
|
||||
import org.schabi.newpipe.info_list.InfoItemDialog
|
||||
import org.schabi.newpipe.ktx.animate
|
||||
import org.schabi.newpipe.ktx.animateHideRecyclerViewAllowingScrolling
|
||||
import org.schabi.newpipe.local.feed.item.StreamItem
|
||||
import org.schabi.newpipe.local.feed.service.FeedLoadService
|
||||
import org.schabi.newpipe.local.subscription.SubscriptionManager
|
||||
import org.schabi.newpipe.player.helper.PlayerHolder
|
||||
import org.schabi.newpipe.util.Localization
|
||||
import org.schabi.newpipe.util.NavigationHelper
|
||||
import org.schabi.newpipe.util.StreamDialogEntry
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.ArrayList
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.max
|
||||
|
||||
class FeedFragment : BaseListFragment<FeedState, Unit>() {
|
||||
class FeedFragment : BaseStateFragment<FeedState>() {
|
||||
private var _feedBinding: FragmentFeedBinding? = null
|
||||
private val feedBinding get() = _feedBinding!!
|
||||
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
private lateinit var viewModel: FeedViewModel
|
||||
@State
|
||||
@JvmField
|
||||
var listState: Parcelable? = null
|
||||
@State @JvmField var listState: Parcelable? = null
|
||||
|
||||
private var groupId = FeedGroupEntity.GROUP_ALL_ID
|
||||
private var groupName = ""
|
||||
private var oldestSubscriptionUpdate: OffsetDateTime? = null
|
||||
|
||||
private lateinit var groupAdapter: GroupAdapter<GroupieViewHolder>
|
||||
@State @JvmField var showPlayedItems: Boolean = true
|
||||
|
||||
private var onSettingsChangeListener: SharedPreferences.OnSharedPreferenceChangeListener? = null
|
||||
private var updateListViewModeOnResume = false
|
||||
|
||||
init {
|
||||
setHasOptionsMenu(true)
|
||||
setUseDefaultStateSaving(false)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@ -71,6 +108,14 @@ class FeedFragment : BaseListFragment<FeedState, Unit>() {
|
||||
groupId = arguments?.getLong(KEY_GROUP_ID, FeedGroupEntity.GROUP_ALL_ID)
|
||||
?: FeedGroupEntity.GROUP_ALL_ID
|
||||
groupName = arguments?.getString(KEY_GROUP_NAME) ?: ""
|
||||
|
||||
onSettingsChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
if (key.equals(getString(R.string.list_view_mode_key))) {
|
||||
updateListViewModeOnResume = true
|
||||
}
|
||||
}
|
||||
PreferenceManager.getDefaultSharedPreferences(activity)
|
||||
.registerOnSharedPreferenceChangeListener(onSettingsChangeListener)
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
@ -82,8 +127,17 @@ class FeedFragment : BaseListFragment<FeedState, Unit>() {
|
||||
_feedBinding = FragmentFeedBinding.bind(rootView)
|
||||
super.onViewCreated(rootView, savedInstanceState)
|
||||
|
||||
viewModel = ViewModelProvider(this, FeedViewModel.Factory(requireContext(), groupId)).get(FeedViewModel::class.java)
|
||||
viewModel.stateLiveData.observe(viewLifecycleOwner) { it?.let(::handleResult) }
|
||||
val factory = FeedViewModel.Factory(requireContext(), groupId, showPlayedItems)
|
||||
viewModel = ViewModelProvider(this, factory).get(FeedViewModel::class.java)
|
||||
viewModel.stateLiveData.observe(viewLifecycleOwner, { it?.let(::handleResult) })
|
||||
|
||||
groupAdapter = GroupAdapter<GroupieViewHolder>().apply {
|
||||
setOnItemClickListener(listenerStreamItem)
|
||||
setOnItemLongClickListener(listenerStreamItem)
|
||||
}
|
||||
|
||||
feedBinding.itemsList.adapter = groupAdapter
|
||||
setupListViewMode()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
@ -94,6 +148,23 @@ class FeedFragment : BaseListFragment<FeedState, Unit>() {
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
updateRelativeTimeViews()
|
||||
|
||||
if (updateListViewModeOnResume) {
|
||||
updateListViewModeOnResume = false
|
||||
|
||||
setupListViewMode()
|
||||
if (viewModel.stateLiveData.value != null) {
|
||||
handleResult(viewModel.stateLiveData.value!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setupListViewMode() {
|
||||
// does everything needed to setup the layouts for grid or list modes
|
||||
groupAdapter.spanCount = if (shouldUseGridLayout()) getGridSpanCount() else 1
|
||||
feedBinding.itemsList.layoutManager = GridLayoutManager(requireContext(), groupAdapter.spanCount).apply {
|
||||
spanSizeLookup = groupAdapter.spanSizeLookup
|
||||
}
|
||||
}
|
||||
|
||||
override fun setUserVisibleHint(isVisibleToUser: Boolean) {
|
||||
@ -116,21 +187,21 @@ class FeedFragment : BaseListFragment<FeedState, Unit>() {
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||
super.onCreateOptionsMenu(menu, inflater)
|
||||
|
||||
activity.supportActionBar?.setDisplayShowTitleEnabled(true)
|
||||
activity.supportActionBar?.setTitle(R.string.fragment_feed_title)
|
||||
activity.supportActionBar?.subtitle = groupName
|
||||
|
||||
inflater.inflate(R.menu.menu_feed_fragment, menu)
|
||||
|
||||
if (useAsFrontPage) {
|
||||
menu.findItem(R.id.menu_item_feed_help).setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER)
|
||||
}
|
||||
updateTogglePlayedItemsButton(menu.findItem(R.id.menu_item_feed_toggle_played_items))
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
if (item.itemId == R.id.menu_item_feed_help) {
|
||||
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||
|
||||
val usingDedicatedMethod = sharedPreferences.getBoolean(getString(R.string.feed_use_dedicated_fetch_method_key), false)
|
||||
val usingDedicatedMethod = sharedPreferences
|
||||
.getBoolean(getString(R.string.feed_use_dedicated_fetch_method_key), false)
|
||||
val enableDisableButtonText = when {
|
||||
usingDedicatedMethod -> R.string.feed_use_dedicated_fetch_method_disable_button
|
||||
else -> R.string.feed_use_dedicated_fetch_method_enable_button
|
||||
@ -147,6 +218,10 @@ class FeedFragment : BaseListFragment<FeedState, Unit>() {
|
||||
.create()
|
||||
.show()
|
||||
return true
|
||||
} else if (item.itemId == R.id.menu_item_feed_toggle_played_items) {
|
||||
showPlayedItems = !item.isChecked
|
||||
updateTogglePlayedItemsButton(item)
|
||||
viewModel.togglePlayedItems(showPlayedItems)
|
||||
}
|
||||
|
||||
return super.onOptionsItemSelected(item)
|
||||
@ -158,18 +233,34 @@ class FeedFragment : BaseListFragment<FeedState, Unit>() {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
disposables.dispose()
|
||||
if (onSettingsChangeListener != null) {
|
||||
PreferenceManager.getDefaultSharedPreferences(activity)
|
||||
.unregisterOnSharedPreferenceChangeListener(onSettingsChangeListener)
|
||||
onSettingsChangeListener = null
|
||||
}
|
||||
|
||||
super.onDestroy()
|
||||
activity?.supportActionBar?.subtitle = null
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
feedBinding.itemsList.adapter = null
|
||||
_feedBinding = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
// /////////////////////////////////////////////////////////////////////////
|
||||
private fun updateTogglePlayedItemsButton(menuItem: MenuItem) {
|
||||
menuItem.isChecked = showPlayedItems
|
||||
menuItem.icon = AppCompatResources.getDrawable(
|
||||
requireContext(),
|
||||
if (showPlayedItems) R.drawable.ic_visibility_on else R.drawable.ic_visibility_off
|
||||
)
|
||||
}
|
||||
|
||||
// //////////////////////////////////////////////////////////////////////////
|
||||
// Handling
|
||||
// /////////////////////////////////////////////////////////////////////////
|
||||
// //////////////////////////////////////////////////////////////////////////
|
||||
|
||||
override fun showLoading() {
|
||||
super.showLoading()
|
||||
@ -181,6 +272,7 @@ class FeedFragment : BaseListFragment<FeedState, Unit>() {
|
||||
|
||||
override fun hideLoading() {
|
||||
super.hideLoading()
|
||||
feedBinding.itemsList.animate(true, 0)
|
||||
feedBinding.refreshRootView.animate(true, 200)
|
||||
feedBinding.loadingProgressText.animate(false, 0)
|
||||
feedBinding.swipeRefreshLayout.isRefreshing = false
|
||||
@ -206,7 +298,6 @@ class FeedFragment : BaseListFragment<FeedState, Unit>() {
|
||||
|
||||
override fun handleError() {
|
||||
super.handleError()
|
||||
infoListAdapter.clearStreamItemList()
|
||||
feedBinding.itemsList.animateHideRecyclerViewAllowingScrolling()
|
||||
feedBinding.refreshRootView.animate(false, 0)
|
||||
feedBinding.loadingProgressText.animate(false, 0)
|
||||
@ -234,24 +325,96 @@ class FeedFragment : BaseListFragment<FeedState, Unit>() {
|
||||
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) {
|
||||
infoListAdapter.setInfoItemList(loadedState.items)
|
||||
|
||||
val itemVersion = if (shouldUseGridLayout()) {
|
||||
StreamItem.ItemVersion.GRID
|
||||
} else {
|
||||
StreamItem.ItemVersion.NORMAL
|
||||
}
|
||||
loadedState.items.forEach { it.itemVersion = itemVersion }
|
||||
|
||||
groupAdapter.updateAsync(loadedState.items, false, null)
|
||||
|
||||
listState?.run {
|
||||
feedBinding.itemsList.layoutManager?.onRestoreInstanceState(listState)
|
||||
listState = null
|
||||
}
|
||||
|
||||
oldestSubscriptionUpdate = loadedState.oldestUpdate
|
||||
|
||||
val loadedCount = loadedState.notLoadedCount > 0
|
||||
feedBinding.refreshSubtitleText.isVisible = loadedCount
|
||||
if (loadedCount) {
|
||||
val feedsNotLoaded = loadedState.notLoadedCount > 0
|
||||
feedBinding.refreshSubtitleText.isVisible = feedsNotLoaded
|
||||
if (feedsNotLoaded) {
|
||||
feedBinding.refreshSubtitleText.text = getString(
|
||||
R.string.feed_subscription_not_loaded_count,
|
||||
loadedState.notLoadedCount
|
||||
)
|
||||
}
|
||||
|
||||
if (oldestSubscriptionUpdate != loadedState.oldestUpdate ||
|
||||
(oldestSubscriptionUpdate == null && loadedState.oldestUpdate == null)
|
||||
) {
|
||||
// ignore errors if they have already been handled for the current update
|
||||
handleItemsErrors(loadedState.itemsErrors)
|
||||
}
|
||||
oldestSubscriptionUpdate = loadedState.oldestUpdate
|
||||
|
||||
if (loadedState.items.isEmpty()) {
|
||||
showEmptyState()
|
||||
} else {
|
||||
@ -269,9 +432,78 @@ class FeedFragment : BaseListFragment<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() {
|
||||
updateRefreshViewState()
|
||||
infoListAdapter.notifyDataSetChanged()
|
||||
groupAdapter.notifyItemRangeChanged(
|
||||
0, groupAdapter.itemCount,
|
||||
StreamItem.UPDATE_RELATIVE_TIME
|
||||
)
|
||||
}
|
||||
|
||||
private fun updateRefreshViewState() {
|
||||
@ -286,8 +518,6 @@ class FeedFragment : BaseListFragment<FeedState, Unit>() {
|
||||
// /////////////////////////////////////////////////////////////////////////
|
||||
|
||||
override fun doInitialLoadLogic() {}
|
||||
override fun loadMoreItems() {}
|
||||
override fun hasMoreItems() = false
|
||||
|
||||
override fun reloadContent() {
|
||||
getActivity()?.startService(
|
||||
@ -298,6 +528,35 @@ class FeedFragment : BaseListFragment<FeedState, Unit>() {
|
||||
listState = null
|
||||
}
|
||||
|
||||
// /////////////////////////////////////////////////////////////////////////
|
||||
// Grid Mode
|
||||
// /////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// TODO: Move these out of this class, as it can be reused
|
||||
|
||||
private fun shouldUseGridLayout(): Boolean {
|
||||
val listMode = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||
.getString(getString(R.string.list_view_mode_key), getString(R.string.list_view_mode_value))
|
||||
|
||||
return when (listMode) {
|
||||
getString(R.string.list_view_mode_auto_key) -> {
|
||||
val configuration = resources.configuration
|
||||
|
||||
(
|
||||
configuration.orientation == Configuration.ORIENTATION_LANDSCAPE &&
|
||||
configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE)
|
||||
)
|
||||
}
|
||||
getString(R.string.list_view_mode_grid_key) -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun getGridSpanCount(): Int {
|
||||
val minWidth = resources.getDimensionPixelSize(R.dimen.video_item_grid_thumbnail_image_width)
|
||||
return max(1, floor(resources.displayMetrics.widthPixels / minWidth.toDouble()).toInt())
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val KEY_GROUP_ID = "ARG_GROUP_ID"
|
||||
const val KEY_GROUP_NAME = "ARG_GROUP_NAME"
|
||||
|
@ -1,7 +1,7 @@
|
||||
package org.schabi.newpipe.local.feed
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import org.schabi.newpipe.local.feed.item.StreamItem
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
sealed class FeedState {
|
||||
@ -12,7 +12,7 @@ sealed class FeedState {
|
||||
) : FeedState()
|
||||
|
||||
data class LoadedState(
|
||||
val items: List<StreamInfoItem>,
|
||||
val items: List<StreamItem>,
|
||||
val oldestUpdate: OffsetDateTime? = null,
|
||||
val notLoadedCount: Long,
|
||||
val itemsErrors: List<Throwable> = emptyList()
|
||||
|
@ -8,9 +8,11 @@ import androidx.lifecycle.ViewModelProvider
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.functions.Function4
|
||||
import io.reactivex.rxjava3.processors.BehaviorProcessor
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import org.schabi.newpipe.database.stream.StreamWithState
|
||||
import org.schabi.newpipe.local.feed.item.StreamItem
|
||||
import org.schabi.newpipe.local.feed.service.FeedEventManager
|
||||
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ErrorResultEvent
|
||||
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.IdleEvent
|
||||
@ -20,26 +22,33 @@ import org.schabi.newpipe.util.DEFAULT_THROTTLE_TIMEOUT
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class FeedViewModel(applicationContext: Context, val groupId: Long = FeedGroupEntity.GROUP_ALL_ID) : ViewModel() {
|
||||
class Factory(val context: Context, val groupId: Long = FeedGroupEntity.GROUP_ALL_ID) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return FeedViewModel(context.applicationContext, groupId) as T
|
||||
}
|
||||
}
|
||||
|
||||
class FeedViewModel(
|
||||
applicationContext: Context,
|
||||
groupId: Long = FeedGroupEntity.GROUP_ALL_ID,
|
||||
initialShowPlayedItems: Boolean = true
|
||||
) : ViewModel() {
|
||||
private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(applicationContext)
|
||||
|
||||
private val toggleShowPlayedItems = BehaviorProcessor.create<Boolean>()
|
||||
private val streamItems = toggleShowPlayedItems
|
||||
.startWithItem(initialShowPlayedItems)
|
||||
.distinctUntilChanged()
|
||||
.switchMap { showPlayedItems ->
|
||||
feedDatabaseManager.getStreams(groupId, showPlayedItems)
|
||||
}
|
||||
|
||||
private val mutableStateLiveData = MutableLiveData<FeedState>()
|
||||
val stateLiveData: LiveData<FeedState> = mutableStateLiveData
|
||||
|
||||
private var combineDisposable = Flowable
|
||||
.combineLatest(
|
||||
FeedEventManager.events(),
|
||||
feedDatabaseManager.asStreamItems(groupId),
|
||||
streamItems,
|
||||
feedDatabaseManager.notLoadedCount(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())
|
||||
}
|
||||
)
|
||||
@ -49,9 +58,9 @@ class FeedViewModel(applicationContext: Context, val groupId: Long = FeedGroupEn
|
||||
.subscribe { (event, listFromDB, notLoadedCount, oldestUpdate) ->
|
||||
mutableStateLiveData.postValue(
|
||||
when (event) {
|
||||
is IdleEvent -> FeedState.LoadedState(listFromDB, oldestUpdate, notLoadedCount)
|
||||
is IdleEvent -> FeedState.LoadedState(listFromDB.map { e -> StreamItem(e) }, oldestUpdate, notLoadedCount)
|
||||
is ProgressEvent -> FeedState.ProgressState(event.currentProgress, event.maxProgress, event.progressMessage)
|
||||
is SuccessResultEvent -> FeedState.LoadedState(listFromDB, oldestUpdate, notLoadedCount, event.itemsErrors)
|
||||
is SuccessResultEvent -> FeedState.LoadedState(listFromDB.map { e -> StreamItem(e) }, oldestUpdate, notLoadedCount, event.itemsErrors)
|
||||
is ErrorResultEvent -> FeedState.ErrorState(event.error)
|
||||
}
|
||||
)
|
||||
@ -66,5 +75,20 @@ class FeedViewModel(applicationContext: Context, val groupId: Long = FeedGroupEn
|
||||
combineDisposable.dispose()
|
||||
}
|
||||
|
||||
private data class CombineResultHolder(val t1: FeedEventManager.Event, val t2: List<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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -48,9 +48,7 @@ import org.schabi.newpipe.MainActivity.DEBUG
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||
import org.schabi.newpipe.extractor.ListInfo
|
||||
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import org.schabi.newpipe.ktx.isNetworkRelated
|
||||
import org.schabi.newpipe.local.feed.FeedDatabaseManager
|
||||
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ErrorResultEvent
|
||||
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ProgressEvent
|
||||
@ -58,7 +56,6 @@ import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.SuccessResul
|
||||
import org.schabi.newpipe.local.feed.service.FeedEventManager.postEvent
|
||||
import org.schabi.newpipe.local.subscription.SubscriptionManager
|
||||
import org.schabi.newpipe.util.ExtractorHelper
|
||||
import java.io.IOException
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.util.concurrent.TimeUnit
|
||||
@ -162,7 +159,7 @@ class FeedLoadService : Service() {
|
||||
// Loading & Handling
|
||||
// /////////////////////////////////////////////////////////////////////////
|
||||
|
||||
private class RequestException(val subscriptionId: Long, message: String, cause: Throwable) : Exception(message, cause) {
|
||||
class RequestException(val subscriptionId: Long, message: String, cause: Throwable) : Exception(message, cause) {
|
||||
companion object {
|
||||
fun wrapList(subscriptionId: Long, info: ListInfo<StreamInfoItem>): List<Throwable> {
|
||||
val toReturn = ArrayList<Throwable>(info.errors.size)
|
||||
@ -209,29 +206,40 @@ class FeedLoadService : Service() {
|
||||
.filter { !cancelSignal.get() }
|
||||
|
||||
.map { subscriptionEntity ->
|
||||
var error: Throwable? = null
|
||||
try {
|
||||
val listInfo = if (useFeedExtractor) {
|
||||
ExtractorHelper
|
||||
.getFeedInfoFallbackToChannelInfo(subscriptionEntity.serviceId, subscriptionEntity.url)
|
||||
.onErrorReturn {
|
||||
error = it // store error, otherwise wrapped into RuntimeException
|
||||
throw it
|
||||
}
|
||||
.blockingGet()
|
||||
} else {
|
||||
ExtractorHelper
|
||||
.getChannelInfo(subscriptionEntity.serviceId, subscriptionEntity.url, true)
|
||||
.onErrorReturn {
|
||||
error = it // store error, otherwise wrapped into RuntimeException
|
||||
throw it
|
||||
}
|
||||
.blockingGet()
|
||||
} as ListInfo<StreamInfoItem>
|
||||
|
||||
return@map Notification.createOnNext(Pair(subscriptionEntity.uid, listInfo))
|
||||
} catch (e: Throwable) {
|
||||
if (error == null) {
|
||||
// do this to prevent blockingGet() from wrapping into RuntimeException
|
||||
error = e
|
||||
}
|
||||
|
||||
val request = "${subscriptionEntity.serviceId}:${subscriptionEntity.url}"
|
||||
val wrapper = RequestException(subscriptionEntity.uid, request, e)
|
||||
val wrapper = RequestException(subscriptionEntity.uid, request, error!!)
|
||||
return@map Notification.createOnError<Pair<Long, ListInfo<StreamInfoItem>>>(wrapper)
|
||||
}
|
||||
}
|
||||
.sequential()
|
||||
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnNext(errorHandlingConsumer)
|
||||
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.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>>>>
|
||||
get() = Consumer { onItemCompleted(it.value?.second?.name) }
|
||||
|
||||
|
@ -211,11 +211,11 @@ public class HistoryRecordManager {
|
||||
|
||||
public Maybe<StreamStateEntity> loadStreamState(final PlayQueueItem queueItem) {
|
||||
return queueItem.getStream()
|
||||
.map((info) -> streamTable.upsert(new StreamEntity(info)))
|
||||
.map(info -> streamTable.upsert(new StreamEntity(info)))
|
||||
.flatMapPublisher(streamStateTable::getState)
|
||||
.firstElement()
|
||||
.flatMap(list -> list.isEmpty() ? Maybe.empty() : Maybe.just(list.get(0)))
|
||||
.filter(state -> state.isValid((int) queueItem.getDuration()))
|
||||
.filter(state -> state.isValid(queueItem.getDuration()))
|
||||
.subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
@ -224,18 +224,16 @@ public class HistoryRecordManager {
|
||||
.flatMapPublisher(streamStateTable::getState)
|
||||
.firstElement()
|
||||
.flatMap(list -> list.isEmpty() ? Maybe.empty() : Maybe.just(list.get(0)))
|
||||
.filter(state -> state.isValid((int) info.getDuration()))
|
||||
.filter(state -> state.isValid(info.getDuration()))
|
||||
.subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
public Completable saveStreamState(@NonNull final StreamInfo info, final long progressTime) {
|
||||
public Completable saveStreamState(@NonNull final StreamInfo info, final long progressMillis) {
|
||||
return Completable.fromAction(() -> database.runInTransaction(() -> {
|
||||
final long streamId = streamTable.upsert(new StreamEntity(info));
|
||||
final StreamStateEntity state = new StreamStateEntity(streamId, progressTime);
|
||||
if (state.isValid((int) info.getDuration())) {
|
||||
final StreamStateEntity state = new StreamStateEntity(streamId, progressMillis);
|
||||
if (state.isValid(info.getDuration())) {
|
||||
streamStateTable.upsert(state);
|
||||
} else {
|
||||
streamStateTable.deleteState(streamId);
|
||||
}
|
||||
})).subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
@ -36,7 +36,7 @@ import org.schabi.newpipe.player.helper.PlayerHolder;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||
import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
|
||||
import org.schabi.newpipe.settings.HistorySettingsFragment;
|
||||
import org.schabi.newpipe.util.KoreUtil;
|
||||
import org.schabi.newpipe.util.external_communication.KoreUtils;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.OnClickGesture;
|
||||
import org.schabi.newpipe.util.StreamDialogEntry;
|
||||
@ -111,7 +111,8 @@ public class StatisticsPlaylistFragment
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) {
|
||||
public void onCreateOptionsMenu(@NonNull final Menu menu,
|
||||
@NonNull final MenuInflater inflater) {
|
||||
super.onCreateOptionsMenu(menu, inflater);
|
||||
inflater.inflate(R.menu.menu_history, menu);
|
||||
}
|
||||
@ -359,7 +360,7 @@ public class StatisticsPlaylistFragment
|
||||
));
|
||||
}
|
||||
entries.add(StreamDialogEntry.open_in_browser);
|
||||
if (KoreUtil.shouldShowPlayWithKodi(context, infoItem.getServiceId())) {
|
||||
if (KoreUtils.shouldShowPlayWithKodi(context, infoItem.getServiceId())) {
|
||||
entries.add(StreamDialogEntry.play_with_kodi);
|
||||
}
|
||||
|
||||
|
@ -68,11 +68,11 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder {
|
||||
R.color.duration_background_color));
|
||||
itemDurationView.setVisibility(View.VISIBLE);
|
||||
|
||||
if (item.getProgressTime() > 0) {
|
||||
if (item.getProgressMillis() > 0) {
|
||||
itemProgressView.setVisibility(View.VISIBLE);
|
||||
itemProgressView.setMax((int) item.getStreamEntity().getDuration());
|
||||
itemProgressView.setProgress((int) TimeUnit.MILLISECONDS
|
||||
.toSeconds(item.getProgressTime()));
|
||||
.toSeconds(item.getProgressMillis()));
|
||||
} else {
|
||||
itemProgressView.setVisibility(View.GONE);
|
||||
}
|
||||
@ -109,14 +109,14 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder {
|
||||
}
|
||||
final PlaylistStreamEntry item = (PlaylistStreamEntry) localItem;
|
||||
|
||||
if (item.getProgressTime() > 0 && item.getStreamEntity().getDuration() > 0) {
|
||||
if (item.getProgressMillis() > 0 && item.getStreamEntity().getDuration() > 0) {
|
||||
itemProgressView.setMax((int) item.getStreamEntity().getDuration());
|
||||
if (itemProgressView.getVisibility() == View.VISIBLE) {
|
||||
itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS
|
||||
.toSeconds(item.getProgressTime()));
|
||||
.toSeconds(item.getProgressMillis()));
|
||||
} else {
|
||||
itemProgressView.setProgress((int) TimeUnit.MILLISECONDS
|
||||
.toSeconds(item.getProgressTime()));
|
||||
.toSeconds(item.getProgressMillis()));
|
||||
ViewUtils.animate(itemProgressView, true, 500);
|
||||
}
|
||||
} else if (itemProgressView.getVisibility() == View.VISIBLE) {
|
||||
|
@ -96,11 +96,11 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder {
|
||||
R.color.duration_background_color));
|
||||
itemDurationView.setVisibility(View.VISIBLE);
|
||||
|
||||
if (item.getProgressTime() > 0) {
|
||||
if (item.getProgressMillis() > 0) {
|
||||
itemProgressView.setVisibility(View.VISIBLE);
|
||||
itemProgressView.setMax((int) item.getStreamEntity().getDuration());
|
||||
itemProgressView.setProgress((int) TimeUnit.MILLISECONDS
|
||||
.toSeconds(item.getProgressTime()));
|
||||
.toSeconds(item.getProgressMillis()));
|
||||
} else {
|
||||
itemProgressView.setVisibility(View.GONE);
|
||||
}
|
||||
@ -140,14 +140,14 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder {
|
||||
}
|
||||
final StreamStatisticsEntry item = (StreamStatisticsEntry) localItem;
|
||||
|
||||
if (item.getProgressTime() > 0 && item.getStreamEntity().getDuration() > 0) {
|
||||
if (item.getProgressMillis() > 0 && item.getStreamEntity().getDuration() > 0) {
|
||||
itemProgressView.setMax((int) item.getStreamEntity().getDuration());
|
||||
if (itemProgressView.getVisibility() == View.VISIBLE) {
|
||||
itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS
|
||||
.toSeconds(item.getProgressTime()));
|
||||
.toSeconds(item.getProgressMillis()));
|
||||
} else {
|
||||
itemProgressView.setProgress((int) TimeUnit.MILLISECONDS
|
||||
.toSeconds(item.getProgressTime()));
|
||||
.toSeconds(item.getProgressMillis()));
|
||||
ViewUtils.animate(itemProgressView, true, 500);
|
||||
}
|
||||
} else if (itemProgressView.getVisibility() == View.VISIBLE) {
|
||||
|
@ -44,7 +44,7 @@ import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||
import org.schabi.newpipe.player.helper.PlayerHolder;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||
import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
|
||||
import org.schabi.newpipe.util.KoreUtil;
|
||||
import org.schabi.newpipe.util.external_communication.KoreUtils;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.OnClickGesture;
|
||||
@ -248,7 +248,8 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) {
|
||||
public void onCreateOptionsMenu(@NonNull final Menu menu,
|
||||
@NonNull final MenuInflater inflater) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onCreateOptionsMenu() called with: "
|
||||
+ "menu = [" + menu + "], inflater = [" + inflater + "]");
|
||||
@ -770,7 +771,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
||||
));
|
||||
}
|
||||
entries.add(StreamDialogEntry.open_in_browser);
|
||||
if (KoreUtil.shouldShowPlayWithKodi(context, infoItem.getServiceId())) {
|
||||
if (KoreUtils.shouldShowPlayWithKodi(context, infoItem.getServiceId())) {
|
||||
entries.add(StreamDialogEntry.play_with_kodi);
|
||||
}
|
||||
|
||||
|
@ -23,13 +23,9 @@ public class ImportConfirmationDialog extends DialogFragment {
|
||||
|
||||
public static void show(@NonNull final Fragment fragment,
|
||||
@NonNull final Intent resultServiceIntent) {
|
||||
if (fragment.getFragmentManager() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final ImportConfirmationDialog confirmationDialog = new ImportConfirmationDialog();
|
||||
confirmationDialog.setResultServiceIntent(resultServiceIntent);
|
||||
confirmationDialog.show(fragment.getFragmentManager(), null);
|
||||
confirmationDialog.show(fragment.getParentFragmentManager(), null);
|
||||
}
|
||||
|
||||
public void setResultServiceIntent(final Intent resultServiceIntent) {
|
||||
|
@ -8,7 +8,6 @@ import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
import android.os.Environment
|
||||
import android.os.Parcelable
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
@ -16,12 +15,13 @@ import android.view.MenuInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.ActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import com.nononsenseapps.filepicker.Utils
|
||||
import com.xwray.groupie.Group
|
||||
import com.xwray.groupie.GroupAdapter
|
||||
import com.xwray.groupie.Item
|
||||
@ -52,17 +52,15 @@ import org.schabi.newpipe.local.subscription.item.HeaderWithMenuItem
|
||||
import org.schabi.newpipe.local.subscription.item.HeaderWithMenuItem.Companion.PAYLOAD_UPDATE_VISIBILITY_MENU_ITEM
|
||||
import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService
|
||||
import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService.EXPORT_COMPLETE_ACTION
|
||||
import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService.KEY_FILE_PATH
|
||||
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService
|
||||
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.IMPORT_COMPLETE_ACTION
|
||||
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_MODE
|
||||
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE
|
||||
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.PREVIOUS_EXPORT_MODE
|
||||
import org.schabi.newpipe.util.FilePickerActivityHelper
|
||||
import org.schabi.newpipe.streams.io.StoredFileHelper
|
||||
import org.schabi.newpipe.util.NavigationHelper
|
||||
import org.schabi.newpipe.util.OnClickGesture
|
||||
import org.schabi.newpipe.util.ShareUtils
|
||||
import java.io.File
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
@ -86,6 +84,11 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
|
||||
private lateinit var feedGroupsSortMenuItem: HeaderWithMenuItem
|
||||
private val subscriptionsSection = Section()
|
||||
|
||||
private val requestExportLauncher =
|
||||
registerForActivityResult(StartActivityForResult(), this::requestExportResult)
|
||||
private val requestImportLauncher =
|
||||
registerForActivityResult(StartActivityForResult(), this::requestImportResult)
|
||||
|
||||
@State
|
||||
@JvmField
|
||||
var itemsListState: Parcelable? = null
|
||||
@ -188,44 +191,39 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
|
||||
}
|
||||
|
||||
private fun onImportPreviousSelected() {
|
||||
startActivityForResult(FilePickerActivityHelper.chooseSingleFile(activity), REQUEST_IMPORT_CODE)
|
||||
requestImportLauncher.launch(StoredFileHelper.getPicker(activity))
|
||||
}
|
||||
|
||||
private fun onExportSelected() {
|
||||
val date = SimpleDateFormat("yyyyMMddHHmm", Locale.ENGLISH).format(Date())
|
||||
val exportName = "newpipe_subscriptions_$date.json"
|
||||
val exportFile = File(Environment.getExternalStorageDirectory(), exportName)
|
||||
|
||||
startActivityForResult(FilePickerActivityHelper.chooseFileToSave(activity, exportFile.absolutePath), REQUEST_EXPORT_CODE)
|
||||
requestExportLauncher.launch(
|
||||
StoredFileHelper.getNewPicker(activity, exportName, "application/json", null)
|
||||
)
|
||||
}
|
||||
|
||||
private fun openReorderDialog() {
|
||||
FeedGroupReorderDialog().show(requireFragmentManager(), null)
|
||||
FeedGroupReorderDialog().show(parentFragmentManager, null)
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
if (data != null && data.data != null && resultCode == Activity.RESULT_OK) {
|
||||
if (requestCode == REQUEST_EXPORT_CODE) {
|
||||
val exportFile = Utils.getFileForUri(data.data!!)
|
||||
val parentFile = exportFile.parentFile!!
|
||||
if (!parentFile.canWrite() || !parentFile.canRead()) {
|
||||
Toast.makeText(activity, R.string.invalid_directory, Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
activity.startService(
|
||||
Intent(activity, SubscriptionsExportService::class.java)
|
||||
.putExtra(KEY_FILE_PATH, exportFile.absolutePath)
|
||||
)
|
||||
}
|
||||
} else if (requestCode == REQUEST_IMPORT_CODE) {
|
||||
val path = Utils.getFileForUri(data.data!!).absolutePath
|
||||
ImportConfirmationDialog.show(
|
||||
this,
|
||||
Intent(activity, SubscriptionsImportService::class.java)
|
||||
.putExtra(KEY_MODE, PREVIOUS_EXPORT_MODE)
|
||||
.putExtra(KEY_VALUE, path)
|
||||
)
|
||||
}
|
||||
fun requestExportResult(result: ActivityResult) {
|
||||
if (result.data != null && result.resultCode == Activity.RESULT_OK) {
|
||||
activity.startService(
|
||||
Intent(activity, SubscriptionsExportService::class.java)
|
||||
.putExtra(SubscriptionsExportService.KEY_FILE_PATH, result.data?.data)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun requestImportResult(result: ActivityResult) {
|
||||
if (result.data != null && result.resultCode == Activity.RESULT_OK) {
|
||||
ImportConfirmationDialog.show(
|
||||
this,
|
||||
Intent(activity, SubscriptionsImportService::class.java)
|
||||
.putExtra(KEY_MODE, PREVIOUS_EXPORT_MODE)
|
||||
.putExtra(KEY_VALUE, result.data?.data)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -295,13 +293,17 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
|
||||
|
||||
private fun showLongTapDialog(selectedItem: ChannelInfoItem) {
|
||||
val commands = arrayOf(
|
||||
getString(R.string.share), getString(R.string.open_in_browser),
|
||||
getString(R.string.share),
|
||||
getString(R.string.open_in_browser),
|
||||
getString(R.string.unsubscribe)
|
||||
)
|
||||
|
||||
val actions = DialogInterface.OnClickListener { _, i ->
|
||||
when (i) {
|
||||
0 -> ShareUtils.shareText(requireContext(), selectedItem.name, selectedItem.url)
|
||||
0 -> ShareUtils.shareText(
|
||||
requireContext(), selectedItem.name, selectedItem.url,
|
||||
selectedItem.thumbnailUrl
|
||||
)
|
||||
1 -> ShareUtils.openUrlInBrowser(requireContext(), selectedItem.url)
|
||||
2 -> deleteChannel(selectedItem)
|
||||
}
|
||||
@ -444,9 +446,4 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
@ -12,14 +12,15 @@ import android.widget.Button;
|
||||
import android.widget.EditText;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.activity.result.ActivityResult;
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.StringRes;
|
||||
import androidx.appcompat.app.ActionBar;
|
||||
import androidx.core.text.util.LinkifyCompat;
|
||||
|
||||
import com.nononsenseapps.filepicker.Utils;
|
||||
|
||||
import org.schabi.newpipe.BaseFragment;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.error.ErrorActivity;
|
||||
@ -29,8 +30,8 @@ import org.schabi.newpipe.extractor.NewPipe;
|
||||
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
||||
import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor;
|
||||
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService;
|
||||
import org.schabi.newpipe.streams.io.StoredFileHelper;
|
||||
import org.schabi.newpipe.util.Constants;
|
||||
import org.schabi.newpipe.util.FilePickerActivityHelper;
|
||||
import org.schabi.newpipe.util.ServiceHelper;
|
||||
|
||||
import java.util.Collections;
|
||||
@ -45,8 +46,6 @@ import static org.schabi.newpipe.local.subscription.services.SubscriptionsImport
|
||||
import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE;
|
||||
|
||||
public class SubscriptionsImportFragment extends BaseFragment {
|
||||
private static final int REQUEST_IMPORT_FILE_CODE = 666;
|
||||
|
||||
@State
|
||||
int currentServiceId = Constants.NO_SERVICE_ID;
|
||||
|
||||
@ -64,6 +63,9 @@ public class SubscriptionsImportFragment extends BaseFragment {
|
||||
private EditText inputText;
|
||||
private Button inputButton;
|
||||
|
||||
private final ActivityResultLauncher<Intent> requestImportFileLauncher =
|
||||
registerForActivityResult(new StartActivityForResult(), this::requestImportFileResult);
|
||||
|
||||
public static SubscriptionsImportFragment getInstance(final int serviceId) {
|
||||
final SubscriptionsImportFragment instance = new SubscriptionsImportFragment();
|
||||
instance.setInitialData(serviceId);
|
||||
@ -175,23 +177,19 @@ public class SubscriptionsImportFragment extends BaseFragment {
|
||||
}
|
||||
|
||||
public void onImportFile() {
|
||||
startActivityForResult(FilePickerActivityHelper.chooseSingleFile(activity),
|
||||
REQUEST_IMPORT_FILE_CODE);
|
||||
requestImportFileLauncher.launch(StoredFileHelper.getPicker(activity));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivityResult(final int requestCode, final int resultCode, final Intent data) {
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
if (data == null) {
|
||||
private void requestImportFileResult(final ActivityResult result) {
|
||||
if (result.getData() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (resultCode == Activity.RESULT_OK && requestCode == REQUEST_IMPORT_FILE_CODE
|
||||
&& data.getData() != null) {
|
||||
final String path = Utils.getFileForUri(data.getData()).getAbsolutePath();
|
||||
if (result.getResultCode() == Activity.RESULT_OK && result.getData().getData() != null) {
|
||||
ImportConfirmationDialog.show(this,
|
||||
new Intent(activity, SubscriptionsImportService.class)
|
||||
.putExtra(KEY_MODE, INPUT_STREAM_MODE).putExtra(KEY_VALUE, path)
|
||||
.putExtra(KEY_MODE, INPUT_STREAM_MODE)
|
||||
.putExtra(KEY_VALUE, result.getData().getData())
|
||||
.putExtra(Constants.KEY_SERVICE_ID, currentServiceId));
|
||||
}
|
||||
}
|
||||
|
@ -20,7 +20,7 @@
|
||||
package org.schabi.newpipe.local.subscription.services;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.text.TextUtils;
|
||||
import android.net.Uri;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
|
||||
@ -31,10 +31,11 @@ import org.schabi.newpipe.App;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
||||
import org.schabi.newpipe.extractor.subscription.SubscriptionItem;
|
||||
import org.schabi.newpipe.streams.io.SharpOutputStream;
|
||||
import org.schabi.newpipe.streams.io.StoredFileHelper;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@ -55,8 +56,8 @@ public class SubscriptionsExportService extends BaseImportExportService {
|
||||
+ ".services.SubscriptionsExportService.EXPORT_COMPLETE";
|
||||
|
||||
private Subscription subscription;
|
||||
private File outFile;
|
||||
private FileOutputStream outputStream;
|
||||
private StoredFileHelper outFile;
|
||||
private OutputStream outputStream;
|
||||
|
||||
@Override
|
||||
public int onStartCommand(final Intent intent, final int flags, final int startId) {
|
||||
@ -64,18 +65,18 @@ public class SubscriptionsExportService extends BaseImportExportService {
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
final String path = intent.getStringExtra(KEY_FILE_PATH);
|
||||
if (TextUtils.isEmpty(path)) {
|
||||
final Uri path = intent.getParcelableExtra(KEY_FILE_PATH);
|
||||
if (path == null) {
|
||||
stopAndReportError(new IllegalStateException(
|
||||
"Exporting to a file, but the path is empty or null"),
|
||||
"Exporting to a file, but the path is null"),
|
||||
"Exporting subscriptions");
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
try {
|
||||
outFile = new File(path);
|
||||
outputStream = new FileOutputStream(outFile);
|
||||
} catch (final FileNotFoundException e) {
|
||||
outFile = new StoredFileHelper(this, path, "application/json");
|
||||
outputStream = new SharpOutputStream(outFile.getStream());
|
||||
} catch (final IOException e) {
|
||||
handleError(e);
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
@ -122,8 +123,8 @@ public class SubscriptionsExportService extends BaseImportExportService {
|
||||
.subscribe(getSubscriber());
|
||||
}
|
||||
|
||||
private Subscriber<File> getSubscriber() {
|
||||
return new Subscriber<File>() {
|
||||
private Subscriber<StoredFileHelper> getSubscriber() {
|
||||
return new Subscriber<StoredFileHelper>() {
|
||||
@Override
|
||||
public void onSubscribe(final Subscription s) {
|
||||
subscription = s;
|
||||
@ -131,7 +132,7 @@ public class SubscriptionsExportService extends BaseImportExportService {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNext(final File file) {
|
||||
public void onNext(final StoredFileHelper file) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "startExport() success: file = " + file);
|
||||
}
|
||||
@ -153,7 +154,7 @@ public class SubscriptionsExportService extends BaseImportExportService {
|
||||
};
|
||||
}
|
||||
|
||||
private Function<List<SubscriptionItem>, File> exportToFile() {
|
||||
private Function<List<SubscriptionItem>, StoredFileHelper> exportToFile() {
|
||||
return subscriptionItems -> {
|
||||
ImportExportJsonHelper.writeTo(subscriptionItems, outputStream, eventListener);
|
||||
return outFile;
|
||||
|
@ -20,6 +20,7 @@
|
||||
package org.schabi.newpipe.local.subscription.services;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
|
||||
@ -36,12 +37,11 @@ import org.schabi.newpipe.extractor.NewPipe;
|
||||
import org.schabi.newpipe.extractor.channel.ChannelInfo;
|
||||
import org.schabi.newpipe.extractor.subscription.SubscriptionItem;
|
||||
import org.schabi.newpipe.ktx.ExceptionUtils;
|
||||
import org.schabi.newpipe.streams.io.SharpInputStream;
|
||||
import org.schabi.newpipe.streams.io.StoredFileHelper;
|
||||
import org.schabi.newpipe.util.Constants;
|
||||
import org.schabi.newpipe.util.ExtractorHelper;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
@ -55,6 +55,7 @@ import io.reactivex.rxjava3.functions.Function;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
|
||||
import static org.schabi.newpipe.MainActivity.DEBUG;
|
||||
import static org.schabi.newpipe.streams.io.StoredFileHelper.DEFAULT_MIME;
|
||||
|
||||
public class SubscriptionsImportService extends BaseImportExportService {
|
||||
public static final int CHANNEL_URL_MODE = 0;
|
||||
@ -101,17 +102,18 @@ public class SubscriptionsImportService extends BaseImportExportService {
|
||||
if (currentMode == CHANNEL_URL_MODE) {
|
||||
channelUrl = intent.getStringExtra(KEY_VALUE);
|
||||
} else {
|
||||
final String filePath = intent.getStringExtra(KEY_VALUE);
|
||||
if (TextUtils.isEmpty(filePath)) {
|
||||
final Uri uri = intent.getParcelableExtra(KEY_VALUE);
|
||||
if (uri == null) {
|
||||
stopAndReportError(new IllegalStateException(
|
||||
"Importing from input stream, but file path is empty or null"),
|
||||
"Importing from input stream, but file path is null"),
|
||||
"Importing subscriptions");
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
try {
|
||||
inputStream = new FileInputStream(new File(filePath));
|
||||
} catch (final FileNotFoundException e) {
|
||||
inputStream = new SharpInputStream(
|
||||
new StoredFileHelper(this, uri, DEFAULT_MIME).getStream());
|
||||
} catch (final IOException e) {
|
||||
handleError(e);
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
@ -47,7 +47,7 @@ import java.util.List;
|
||||
|
||||
import static org.schabi.newpipe.player.helper.PlayerHelper.formatSpeed;
|
||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
import static org.schabi.newpipe.util.ShareUtils.shareText;
|
||||
import static org.schabi.newpipe.util.external_communication.ShareUtils.shareText;
|
||||
|
||||
public final class PlayQueueActivity extends AppCompatActivity
|
||||
implements PlayerEventListener, SeekBar.OnSeekBarChangeListener,
|
||||
@ -313,7 +313,8 @@ public final class PlayQueueActivity extends AppCompatActivity
|
||||
final MenuItem share = popupMenu.getMenu().add(RECYCLER_ITEM_POPUP_MENU_GROUP_ID, 3,
|
||||
Menu.NONE, R.string.share);
|
||||
share.setOnMenuItemClickListener(menuItem -> {
|
||||
shareText(getApplicationContext(), item.getTitle(), item.getUrl());
|
||||
shareText(getApplicationContext(), item.getTitle(), item.getUrl(),
|
||||
item.getThumbnailUrl());
|
||||
return true;
|
||||
});
|
||||
|
||||
|
@ -123,11 +123,11 @@ import org.schabi.newpipe.player.resolver.MediaSourceTag;
|
||||
import org.schabi.newpipe.player.resolver.VideoPlaybackResolver;
|
||||
import org.schabi.newpipe.util.DeviceUtils;
|
||||
import org.schabi.newpipe.util.ImageDisplayConstants;
|
||||
import org.schabi.newpipe.util.KoreUtil;
|
||||
import org.schabi.newpipe.util.external_communication.KoreUtils;
|
||||
import org.schabi.newpipe.util.ListHelper;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.SerializedCache;
|
||||
import org.schabi.newpipe.util.ShareUtils;
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||
import org.schabi.newpipe.views.ExpandableSurfaceView;
|
||||
|
||||
import java.io.IOException;
|
||||
@ -532,6 +532,7 @@ public final class Player implements
|
||||
binding.moreOptionsButton.setOnClickListener(this);
|
||||
binding.moreOptionsButton.setOnLongClickListener(this);
|
||||
binding.share.setOnClickListener(this);
|
||||
binding.share.setOnLongClickListener(this);
|
||||
binding.fullScreenButton.setOnClickListener(this);
|
||||
binding.screenRotationButton.setOnClickListener(this);
|
||||
binding.playWithKodi.setOnClickListener(this);
|
||||
@ -670,7 +671,11 @@ public final class Player implements
|
||||
//.doFinally()
|
||||
.subscribe(
|
||||
state -> {
|
||||
newQueue.setRecovery(newQueue.getIndex(), state.getProgressTime());
|
||||
if (!state.isFinished(newQueue.getItem().getDuration())) {
|
||||
// resume playback only if the stream was not played to the end
|
||||
newQueue.setRecovery(newQueue.getIndex(),
|
||||
state.getProgressMillis());
|
||||
}
|
||||
initPlayback(newQueue, repeatMode, playbackSpeed, playbackPitch,
|
||||
playbackSkipSilence, playWhenReady, isMuted);
|
||||
},
|
||||
@ -1032,7 +1037,7 @@ public final class Player implements
|
||||
// show kodi button if it supports the current service and it is enabled in settings
|
||||
binding.playWithKodi.setVisibility(videoPlayerSelected()
|
||||
&& playQueue != null && playQueue.getItem() != null
|
||||
&& KoreUtil.shouldShowPlayWithKodi(context, playQueue.getItem().getServiceId())
|
||||
&& KoreUtils.shouldShowPlayWithKodi(context, playQueue.getItem().getServiceId())
|
||||
? View.VISIBLE : View.GONE);
|
||||
}
|
||||
//endregion
|
||||
@ -1934,9 +1939,7 @@ public final class Player implements
|
||||
break;
|
||||
case com.google.android.exoplayer2.Player.STATE_ENDED: // 4
|
||||
changeState(STATE_COMPLETED);
|
||||
if (currentMetadata != null) {
|
||||
resetStreamProgressState(currentMetadata.getMetadata());
|
||||
}
|
||||
saveStreamProgressStateCompleted();
|
||||
isPrepared = false;
|
||||
break;
|
||||
}
|
||||
@ -2157,7 +2160,10 @@ public final class Player implements
|
||||
|
||||
private void onCompleted() {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onCompleted() called");
|
||||
Log.d(TAG, "onCompleted() called" + (playQueue == null ? ". playQueue is null" : ""));
|
||||
}
|
||||
if (playQueue == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
animate(binding.playPauseButton, false, 0, AnimationType.SCALE_AND_ALPHA, 0,
|
||||
@ -2397,7 +2403,7 @@ public final class Player implements
|
||||
case DISCONTINUITY_REASON_SEEK_ADJUSTMENT:
|
||||
case DISCONTINUITY_REASON_INTERNAL:
|
||||
if (playQueue.getIndex() != newWindowIndex) {
|
||||
resetStreamProgressState(playQueue.getItem());
|
||||
saveStreamProgressStateCompleted(); // current stream has ended
|
||||
playQueue.setIndex(newWindowIndex);
|
||||
}
|
||||
break;
|
||||
@ -2788,61 +2794,47 @@ public final class Player implements
|
||||
}
|
||||
}
|
||||
|
||||
private void saveStreamProgressState(final StreamInfo info, final long progress) {
|
||||
if (info == null) {
|
||||
private void saveStreamProgressState(final long progressMillis) {
|
||||
if (currentMetadata == null
|
||||
|| !prefs.getBoolean(context.getString(R.string.enable_watch_history_key), true)) {
|
||||
return;
|
||||
}
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "saveStreamProgressState() called");
|
||||
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)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnError((e) -> {
|
||||
if (DEBUG) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
})
|
||||
.onErrorComplete()
|
||||
.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);
|
||||
databaseUpdateDisposable
|
||||
.add(recordManager.saveStreamState(currentMetadata.getMetadata(), progressMillis)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnError((e) -> {
|
||||
if (DEBUG) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
})
|
||||
.onErrorComplete()
|
||||
.subscribe());
|
||||
}
|
||||
|
||||
public void saveStreamProgressState() {
|
||||
if (exoPlayerIsNull() || currentMetadata == null) {
|
||||
if (exoPlayerIsNull() || currentMetadata == null || playQueue == null
|
||||
|| playQueue.getIndex() != simpleExoPlayer.getCurrentWindowIndex()) {
|
||||
// Make sure play queue and current window index are equal, to prevent saving state for
|
||||
// the wrong stream on discontinuity (e.g. when the stream just changed but the
|
||||
// playQueue index and currentMetadata still haven't updated)
|
||||
return;
|
||||
}
|
||||
final StreamInfo currentInfo = currentMetadata.getMetadata();
|
||||
if (playQueue != null) {
|
||||
// Save current position. It will help to restore this position once a user
|
||||
// wants to play prev or next stream from the queue
|
||||
playQueue.setRecovery(playQueue.getIndex(), simpleExoPlayer.getContentPosition());
|
||||
// Save current position. It will help to restore this position once a user
|
||||
// wants to play prev or next stream from the queue
|
||||
playQueue.setRecovery(playQueue.getIndex(), simpleExoPlayer.getContentPosition());
|
||||
saveStreamProgressState(simpleExoPlayer.getCurrentPosition());
|
||||
}
|
||||
|
||||
public void saveStreamProgressStateCompleted() {
|
||||
if (currentMetadata != null) {
|
||||
// current stream has ended, so the progress is its duration (+1 to overcome rounding)
|
||||
saveStreamProgressState((currentMetadata.getMetadata().getDuration() + 1) * 1000);
|
||||
}
|
||||
saveStreamProgressState(currentInfo, simpleExoPlayer.getCurrentPosition());
|
||||
}
|
||||
//endregion
|
||||
|
||||
@ -2917,6 +2909,18 @@ public final class Player implements
|
||||
: currentMetadata.getMetadata().getUrl();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private String getVideoUrlAtCurrentTime() {
|
||||
final int timeSeconds = binding.playbackSeekBar.getProgress() / 1000;
|
||||
String videoUrl = getVideoUrl();
|
||||
if (!isLive() && timeSeconds >= 0 && currentMetadata != null
|
||||
&& currentMetadata.getMetadata().getServiceId() == YouTube.getServiceId()) {
|
||||
// Timestamp doesn't make sense in a live stream so drop it
|
||||
videoUrl += ("&t=" + timeSeconds);
|
||||
}
|
||||
return videoUrl;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public String getVideoTitle() {
|
||||
return currentMetadata == null
|
||||
@ -3580,7 +3584,8 @@ public final class Player implements
|
||||
} else if (v.getId() == binding.moreOptionsButton.getId()) {
|
||||
onMoreOptionsClicked();
|
||||
} else if (v.getId() == binding.share.getId()) {
|
||||
onShareClicked();
|
||||
ShareUtils.shareText(context, getVideoTitle(), getVideoUrlAtCurrentTime(),
|
||||
currentItem.getThumbnailUrl());
|
||||
} else if (v.getId() == binding.playWithKodi.getId()) {
|
||||
onPlayWithKodiClicked();
|
||||
} else if (v.getId() == binding.openInBrowser.getId()) {
|
||||
@ -3629,6 +3634,8 @@ public final class Player implements
|
||||
fragmentListener.onMoreOptionsLongClicked();
|
||||
hideControls(0, 0);
|
||||
hideSystemUIIfNeeded();
|
||||
} else if (v.getId() == binding.share.getId()) {
|
||||
ShareUtils.copyToClipboard(context, getVideoUrlAtCurrentTime());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@ -3700,19 +3707,6 @@ public final class Player implements
|
||||
showControls(DEFAULT_CONTROLS_DURATION);
|
||||
}
|
||||
|
||||
private void onShareClicked() {
|
||||
// share video at the current time (youtube.com/watch?v=ID&t=SECONDS)
|
||||
// Timestamp doesn't make sense in a live stream so drop it
|
||||
|
||||
final int ts = binding.playbackSeekBar.getProgress() / 1000;
|
||||
String videoUrl = getVideoUrl();
|
||||
if (!isLive() && ts >= 0 && currentMetadata != null
|
||||
&& currentMetadata.getMetadata().getServiceId() == YouTube.getServiceId()) {
|
||||
videoUrl += ("&t=" + ts);
|
||||
}
|
||||
ShareUtils.shareText(context, getVideoTitle(), videoUrl);
|
||||
}
|
||||
|
||||
private void onPlayWithKodiClicked() {
|
||||
if (currentMetadata != null) {
|
||||
pause();
|
||||
@ -3722,7 +3716,7 @@ public final class Player implements
|
||||
if (DEBUG) {
|
||||
Log.i(TAG, "Failed to start kore", e);
|
||||
}
|
||||
KoreUtil.showInstallKoreDialog(getParentActivity());
|
||||
KoreUtils.showInstallKoreDialog(getParentActivity());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -182,8 +182,10 @@ public class PlayQueueAdapter extends RecyclerView.Adapter<RecyclerView.ViewHold
|
||||
return ITEM_VIEW_TYPE_ID;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public RecyclerView.ViewHolder onCreateViewHolder(final ViewGroup parent, final int type) {
|
||||
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull final ViewGroup parent,
|
||||
final int type) {
|
||||
switch (type) {
|
||||
case FOOTER_VIEW_TYPE_ID:
|
||||
return new HFHolder(footer);
|
||||
@ -197,7 +199,8 @@ public class PlayQueueAdapter extends RecyclerView.Adapter<RecyclerView.ViewHold
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(final RecyclerView.ViewHolder holder, final int position) {
|
||||
public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder,
|
||||
final int position) {
|
||||
if (holder instanceof PlayQueueItemHolder) {
|
||||
final PlayQueueItemHolder itemHolder = (PlayQueueItemHolder) holder;
|
||||
|
||||
@ -207,7 +210,6 @@ public class PlayQueueAdapter extends RecyclerView.Adapter<RecyclerView.ViewHold
|
||||
|
||||
// Check if the current item should be selected/highlighted
|
||||
final boolean isSelected = playQueue.getIndex() == position;
|
||||
itemHolder.itemSelected.setVisibility(isSelected ? View.VISIBLE : View.INVISIBLE);
|
||||
itemHolder.itemView.setSelected(isSelected);
|
||||
} else if (holder instanceof HFHolder && position == playQueue.getStreams().size()
|
||||
&& footer != null && showFooter) {
|
||||
|
@ -37,7 +37,6 @@ public class PlayQueueItemHolder extends RecyclerView.ViewHolder {
|
||||
public final TextView itemDurationView;
|
||||
final TextView itemAdditionalDetailsView;
|
||||
|
||||
final ImageView itemSelected;
|
||||
public final ImageView itemThumbnailView;
|
||||
final ImageView itemHandle;
|
||||
|
||||
@ -49,7 +48,6 @@ public class PlayQueueItemHolder extends RecyclerView.ViewHolder {
|
||||
itemVideoTitleView = v.findViewById(R.id.itemVideoTitleView);
|
||||
itemDurationView = v.findViewById(R.id.itemDurationView);
|
||||
itemAdditionalDetailsView = v.findViewById(R.id.itemAdditionalDetails);
|
||||
itemSelected = v.findViewById(R.id.itemSelected);
|
||||
itemThumbnailView = v.findViewById(R.id.itemThumbnailView);
|
||||
itemHandle = v.findViewById(R.id.itemHandle);
|
||||
}
|
||||
|
@ -6,12 +6,16 @@ import android.view.View;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.StringRes;
|
||||
import androidx.preference.Preference;
|
||||
import androidx.preference.PreferenceFragmentCompat;
|
||||
import androidx.preference.PreferenceManager;
|
||||
|
||||
import org.schabi.newpipe.MainActivity;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public abstract class BasePreferenceFragment extends PreferenceFragmentCompat {
|
||||
protected final String TAG = getClass().getSimpleName() + "@" + Integer.toHexString(hashCode());
|
||||
protected final boolean DEBUG = MainActivity.DEBUG;
|
||||
@ -37,4 +41,11 @@ public abstract class BasePreferenceFragment extends PreferenceFragmentCompat {
|
||||
super.onResume();
|
||||
ThemeHelper.setTitleToAppCompatActivity(getActivity(), getPreferenceScreen().getTitle());
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public final Preference requirePreference(@StringRes final int resId) {
|
||||
final Preference preference = findPreference(getString(resId));
|
||||
Objects.requireNonNull(preference);
|
||||
return preference;
|
||||
}
|
||||
}
|
||||
|
@ -1,21 +1,23 @@
|
||||
package org.schabi.newpipe.settings;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.activity.result.ActivityResult;
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.preference.Preference;
|
||||
import androidx.preference.PreferenceManager;
|
||||
|
||||
import com.nononsenseapps.filepicker.Utils;
|
||||
import com.nostra13.universalimageloader.core.ImageLoader;
|
||||
|
||||
import org.schabi.newpipe.DownloaderImpl;
|
||||
@ -26,74 +28,69 @@ import org.schabi.newpipe.error.ReCaptchaActivity;
|
||||
import org.schabi.newpipe.extractor.NewPipe;
|
||||
import org.schabi.newpipe.extractor.localization.ContentCountry;
|
||||
import org.schabi.newpipe.extractor.localization.Localization;
|
||||
import org.schabi.newpipe.util.FilePathUtils;
|
||||
import org.schabi.newpipe.util.FilePickerActivityHelper;
|
||||
import org.schabi.newpipe.streams.io.StoredFileHelper;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.ZipHelper;
|
||||
|
||||
import java.io.File;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
|
||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
|
||||
public class ContentSettingsFragment extends BasePreferenceFragment {
|
||||
private static final int REQUEST_IMPORT_PATH = 8945;
|
||||
private static final int REQUEST_EXPORT_PATH = 30945;
|
||||
private static final String ZIP_MIME_TYPE = "application/zip";
|
||||
private static final SimpleDateFormat EXPORT_DATE_FORMAT
|
||||
= new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US);
|
||||
|
||||
private ContentSettingsManager manager;
|
||||
|
||||
private String importExportDataPathKey;
|
||||
|
||||
private String thumbnailLoadToggleKey;
|
||||
private String youtubeRestrictedModeEnabledKey;
|
||||
|
||||
@Nullable private Uri lastImportExportDataUri = null;
|
||||
private Localization initialSelectedLocalization;
|
||||
private ContentCountry initialSelectedContentCountry;
|
||||
private String initialLanguage;
|
||||
private final ActivityResultLauncher<Intent> requestImportPathLauncher =
|
||||
registerForActivityResult(new StartActivityForResult(), this::requestImportPathResult);
|
||||
private final ActivityResultLauncher<Intent> requestExportPathLauncher =
|
||||
registerForActivityResult(new StartActivityForResult(), this::requestExportPathResult);
|
||||
|
||||
@Override
|
||||
public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) {
|
||||
final File homeDir = ContextCompat.getDataDir(requireContext());
|
||||
Objects.requireNonNull(homeDir);
|
||||
manager = new ContentSettingsManager(new NewPipeFileLocator(homeDir));
|
||||
manager.deleteSettingsFile();
|
||||
|
||||
addPreferencesFromResource(R.xml.content_settings);
|
||||
|
||||
importExportDataPathKey = getString(R.string.import_export_data_path);
|
||||
final Preference importDataPreference = findPreference(getString(R.string.import_data));
|
||||
importDataPreference.setOnPreferenceClickListener(p -> {
|
||||
final Intent i = new Intent(getActivity(), FilePickerActivityHelper.class)
|
||||
.putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false)
|
||||
.putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, false)
|
||||
.putExtra(FilePickerActivityHelper.EXTRA_MODE,
|
||||
FilePickerActivityHelper.MODE_FILE);
|
||||
final String path = defaultPreferences.getString(importExportDataPathKey, "");
|
||||
if (FilePathUtils.isValidDirectoryPath(path)) {
|
||||
i.putExtra(FilePickerActivityHelper.EXTRA_START_PATH, path);
|
||||
}
|
||||
startActivityForResult(i, REQUEST_IMPORT_PATH);
|
||||
return true;
|
||||
});
|
||||
|
||||
final Preference exportDataPreference = findPreference(getString(R.string.export_data));
|
||||
exportDataPreference.setOnPreferenceClickListener(p -> {
|
||||
final Intent i = new Intent(getActivity(), FilePickerActivityHelper.class)
|
||||
.putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false)
|
||||
.putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, true)
|
||||
.putExtra(FilePickerActivityHelper.EXTRA_MODE,
|
||||
FilePickerActivityHelper.MODE_DIR);
|
||||
final String path = defaultPreferences.getString(importExportDataPathKey, "");
|
||||
if (FilePathUtils.isValidDirectoryPath(path)) {
|
||||
i.putExtra(FilePickerActivityHelper.EXTRA_START_PATH, path);
|
||||
}
|
||||
startActivityForResult(i, REQUEST_EXPORT_PATH);
|
||||
return true;
|
||||
});
|
||||
|
||||
thumbnailLoadToggleKey = getString(R.string.download_thumbnail_key);
|
||||
youtubeRestrictedModeEnabledKey = getString(R.string.youtube_restricted_mode_enabled);
|
||||
|
||||
addPreferencesFromResource(R.xml.content_settings);
|
||||
|
||||
final Preference importDataPreference = requirePreference(R.string.import_data);
|
||||
importDataPreference.setOnPreferenceClickListener((Preference p) -> {
|
||||
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
|
||||
.getPreferredLocalization(requireContext());
|
||||
initialSelectedContentCountry = org.schabi.newpipe.util.Localization
|
||||
@ -101,8 +98,7 @@ public class ContentSettingsFragment extends BasePreferenceFragment {
|
||||
initialLanguage = PreferenceManager
|
||||
.getDefaultSharedPreferences(requireContext()).getString("app_language_key", "en");
|
||||
|
||||
final Preference clearCookiePref = findPreference(getString(R.string.clear_cookie_key));
|
||||
|
||||
final Preference clearCookiePref = requirePreference(R.string.clear_cookie_key);
|
||||
clearCookiePref.setOnPreferenceClickListener(preference -> {
|
||||
defaultPreferences.edit()
|
||||
.putString(getString(R.string.recaptcha_cookies_key), "").apply();
|
||||
@ -163,64 +159,58 @@ public class ContentSettingsFragment extends BasePreferenceFragment {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivityResult(final int requestCode, final int resultCode,
|
||||
@NonNull final Intent data) {
|
||||
private void requestExportPathResult(final ActivityResult result) {
|
||||
assureCorrectAppLanguage(getContext());
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onActivityResult() called with: "
|
||||
+ "requestCode = [" + requestCode + "], "
|
||||
+ "resultCode = [" + resultCode + "], "
|
||||
+ "data = [" + data + "]");
|
||||
}
|
||||
if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) {
|
||||
lastImportExportDataUri = result.getData().getData(); // will be saved only on success
|
||||
|
||||
if ((requestCode == REQUEST_IMPORT_PATH || requestCode == REQUEST_EXPORT_PATH)
|
||||
&& resultCode == Activity.RESULT_OK && data.getData() != null) {
|
||||
final File file = Utils.getFileForUri(data.getData());
|
||||
final StoredFileHelper file
|
||||
= new StoredFileHelper(getContext(), result.getData().getData(), ZIP_MIME_TYPE);
|
||||
|
||||
if (requestCode == REQUEST_EXPORT_PATH) {
|
||||
exportDatabase(file);
|
||||
} else {
|
||||
final AlertDialog.Builder builder = new AlertDialog.Builder(requireActivity());
|
||||
builder.setMessage(R.string.override_current_data)
|
||||
.setPositiveButton(getString(R.string.finish),
|
||||
(d, id) -> importDatabase(file))
|
||||
.setNegativeButton(android.R.string.cancel,
|
||||
(d, id) -> d.cancel());
|
||||
builder.create().show();
|
||||
}
|
||||
exportDatabase(file);
|
||||
}
|
||||
}
|
||||
|
||||
private void exportDatabase(@NonNull final File folder) {
|
||||
try {
|
||||
final SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US);
|
||||
final String path = folder.getAbsolutePath() + "/NewPipeData-"
|
||||
+ sdf.format(new Date()) + ".zip";
|
||||
private void requestImportPathResult(final ActivityResult result) {
|
||||
assureCorrectAppLanguage(getContext());
|
||||
if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) {
|
||||
lastImportExportDataUri = result.getData().getData(); // will be saved only on success
|
||||
|
||||
final StoredFileHelper file
|
||||
= new StoredFileHelper(getContext(), result.getData().getData(), ZIP_MIME_TYPE);
|
||||
|
||||
new AlertDialog.Builder(requireActivity())
|
||||
.setMessage(R.string.override_current_data)
|
||||
.setPositiveButton(R.string.finish, (d, id) ->
|
||||
importDatabase(file))
|
||||
.setNegativeButton(R.string.cancel, (d, id) ->
|
||||
d.cancel())
|
||||
.create()
|
||||
.show();
|
||||
}
|
||||
}
|
||||
|
||||
private void exportDatabase(final StoredFileHelper file) {
|
||||
try {
|
||||
//checkpoint before export
|
||||
NewPipeDatabase.checkpoint();
|
||||
|
||||
final SharedPreferences preferences = PreferenceManager
|
||||
.getDefaultSharedPreferences(requireContext());
|
||||
manager.exportDatabase(preferences, path);
|
||||
|
||||
setImportExportDataPath(folder, false);
|
||||
.getDefaultSharedPreferences(requireContext());
|
||||
manager.exportDatabase(preferences, file);
|
||||
|
||||
saveLastImportExportDataUri(false); // save export path only on success
|
||||
Toast.makeText(getContext(), R.string.export_complete_toast, Toast.LENGTH_SHORT).show();
|
||||
} catch (final Exception e) {
|
||||
ErrorActivity.reportUiErrorInSnackbar(this, "Exporting database", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void importDatabase(@NonNull final File file) {
|
||||
final String filePath = file.getAbsolutePath();
|
||||
|
||||
private void importDatabase(final StoredFileHelper file) {
|
||||
// check if file is supported
|
||||
if (!ZipHelper.isValidZipFile(filePath)) {
|
||||
if (!ZipHelper.isValidZipFile(file)) {
|
||||
Toast.makeText(getContext(), R.string.no_valid_zip_file, Toast.LENGTH_SHORT)
|
||||
.show();
|
||||
.show();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -229,29 +219,29 @@ public class ContentSettingsFragment extends BasePreferenceFragment {
|
||||
throw new Exception("Could not create databases dir");
|
||||
}
|
||||
|
||||
if (!manager.extractDb(filePath)) {
|
||||
if (!manager.extractDb(file)) {
|
||||
Toast.makeText(getContext(), R.string.could_not_import_all_files, Toast.LENGTH_LONG)
|
||||
.show();
|
||||
.show();
|
||||
}
|
||||
|
||||
//If settings file exist, ask if it should be imported.
|
||||
if (manager.extractSettings(filePath)) {
|
||||
// if settings file exist, ask if it should be imported.
|
||||
if (manager.extractSettings(file)) {
|
||||
final AlertDialog.Builder alert = new AlertDialog.Builder(requireContext());
|
||||
alert.setTitle(R.string.import_settings);
|
||||
|
||||
alert.setNegativeButton(android.R.string.no, (dialog, which) -> {
|
||||
dialog.dismiss();
|
||||
finishImport(file);
|
||||
finishImport();
|
||||
});
|
||||
alert.setPositiveButton(getString(R.string.finish), (dialog, which) -> {
|
||||
dialog.dismiss();
|
||||
manager.loadSharedPreferences(PreferenceManager
|
||||
.getDefaultSharedPreferences(requireContext()));
|
||||
finishImport(file);
|
||||
.getDefaultSharedPreferences(requireContext()));
|
||||
finishImport();
|
||||
});
|
||||
alert.show();
|
||||
} else {
|
||||
finishImport(file);
|
||||
finishImport();
|
||||
}
|
||||
} catch (final Exception e) {
|
||||
ErrorActivity.reportUiErrorInSnackbar(this, "Importing database", e);
|
||||
@ -260,39 +250,29 @@ public class ContentSettingsFragment extends BasePreferenceFragment {
|
||||
|
||||
/**
|
||||
* Save import path and restart system.
|
||||
*
|
||||
* @param file The file of the created backup
|
||||
*/
|
||||
private void finishImport(@NonNull final File file) {
|
||||
if (file.getParentFile() != null) {
|
||||
//immediately because app is about to exit
|
||||
setImportExportDataPath(file.getParentFile(), true);
|
||||
}
|
||||
|
||||
private void finishImport() {
|
||||
// save import path only on success; save immediately because app is about to exit
|
||||
saveLastImportExportDataUri(true);
|
||||
// restart app to properly load db
|
||||
System.exit(0);
|
||||
NavigationHelper.restartApp(requireActivity());
|
||||
}
|
||||
|
||||
@SuppressLint("ApplySharedPref")
|
||||
private void setImportExportDataPath(@NonNull final File file, final boolean immediately) {
|
||||
final String directoryPath;
|
||||
if (file.isDirectory()) {
|
||||
directoryPath = file.getAbsolutePath();
|
||||
} else {
|
||||
final File parentFile = file.getParentFile();
|
||||
if (parentFile != null) {
|
||||
directoryPath = parentFile.getAbsolutePath();
|
||||
private Uri getImportExportDataUri() {
|
||||
final String path = defaultPreferences.getString(importExportDataPathKey, null);
|
||||
return isBlank(path) ? null : Uri.parse(path);
|
||||
}
|
||||
|
||||
private void saveLastImportExportDataUri(final boolean immediately) {
|
||||
if (lastImportExportDataUri != null) {
|
||||
final SharedPreferences.Editor editor = defaultPreferences.edit()
|
||||
.putString(importExportDataPathKey, lastImportExportDataUri.toString());
|
||||
if (immediately) {
|
||||
// noinspection ApplySharedPref
|
||||
editor.commit(); // app about to be restarted, commit immediately
|
||||
} else {
|
||||
directoryPath = "";
|
||||
editor.apply();
|
||||
}
|
||||
}
|
||||
final SharedPreferences.Editor editor = defaultPreferences
|
||||
.edit()
|
||||
.putString(importExportDataPathKey, directoryPath);
|
||||
if (immediately) {
|
||||
editor.commit();
|
||||
} else {
|
||||
editor.apply();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,8 @@
|
||||
package org.schabi.newpipe.settings
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import org.schabi.newpipe.streams.io.SharpOutputStream
|
||||
import org.schabi.newpipe.streams.io.StoredFileHelper
|
||||
import org.schabi.newpipe.util.ZipHelper
|
||||
import java.io.BufferedOutputStream
|
||||
import java.io.FileInputStream
|
||||
@ -17,8 +19,9 @@ class ContentSettingsManager(private val fileLocator: NewPipeFileLocator) {
|
||||
* It also creates the file.
|
||||
*/
|
||||
@Throws(Exception::class)
|
||||
fun exportDatabase(preferences: SharedPreferences, outputPath: String) {
|
||||
ZipOutputStream(BufferedOutputStream(FileOutputStream(outputPath)))
|
||||
fun exportDatabase(preferences: SharedPreferences, file: StoredFileHelper) {
|
||||
file.create()
|
||||
ZipOutputStream(BufferedOutputStream(SharpOutputStream(file.stream)))
|
||||
.use { outZip ->
|
||||
ZipHelper.addFileToZip(outZip, fileLocator.db.path, "newpipe.db")
|
||||
|
||||
@ -48,8 +51,8 @@ class ContentSettingsManager(private val fileLocator: NewPipeFileLocator) {
|
||||
return fileLocator.dbDir.exists() || fileLocator.dbDir.mkdir()
|
||||
}
|
||||
|
||||
fun extractDb(filePath: String): Boolean {
|
||||
val success = ZipHelper.extractFileFromZip(filePath, fileLocator.db.path, "newpipe.db")
|
||||
fun extractDb(file: StoredFileHelper): Boolean {
|
||||
val success = ZipHelper.extractFileFromZip(file, fileLocator.db.path, "newpipe.db")
|
||||
if (success) {
|
||||
fileLocator.dbJournal.delete()
|
||||
fileLocator.dbWal.delete()
|
||||
@ -59,9 +62,8 @@ class ContentSettingsManager(private val fileLocator: NewPipeFileLocator) {
|
||||
return success
|
||||
}
|
||||
|
||||
fun extractSettings(filePath: String): Boolean {
|
||||
return ZipHelper
|
||||
.extractFileFromZip(filePath, fileLocator.settings.path, "newpipe.settings")
|
||||
fun extractSettings(file: StoredFileHelper): Boolean {
|
||||
return ZipHelper.extractFileFromZip(file, fileLocator.settings.path, "newpipe.settings")
|
||||
}
|
||||
|
||||
fun loadSharedPreferences(preferences: SharedPreferences) {
|
||||
|
@ -8,11 +8,12 @@ import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.StringRes;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.preference.Preference;
|
||||
import androidx.preference.SwitchPreferenceCompat;
|
||||
|
||||
import com.nononsenseapps.filepicker.Utils;
|
||||
|
||||
@ -26,7 +27,7 @@ import java.net.URI;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import us.shandian.giga.io.StoredDirectoryHelper;
|
||||
import org.schabi.newpipe.streams.io.StoredDirectoryHelper;
|
||||
|
||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
|
||||
@ -57,13 +58,23 @@ public class DownloadSettingsFragment extends BasePreferenceFragment {
|
||||
prefPathAudio = findPreference(downloadPathAudioPreference);
|
||||
prefStorageAsk = findPreference(downloadStorageAsk);
|
||||
|
||||
final SwitchPreferenceCompat prefUseSaf = findPreference(storageUseSafPreference);
|
||||
prefUseSaf.setDefaultValue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP);
|
||||
prefUseSaf.setChecked(NewPipeSettings.useStorageAccessFramework(ctx));
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
|
||||
|| Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
|
||||
prefUseSaf.setEnabled(false);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
prefUseSaf.setSummary(R.string.downloads_storage_use_saf_summary_api_29);
|
||||
} else {
|
||||
prefUseSaf.setSummary(R.string.downloads_storage_use_saf_summary_api_19);
|
||||
}
|
||||
prefStorageAsk.setSummary(R.string.downloads_storage_ask_summary_no_saf_notice);
|
||||
}
|
||||
|
||||
updatePreferencesSummary();
|
||||
updatePathPickers(!defaultPreferences.getBoolean(downloadStorageAsk, false));
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
prefStorageAsk.setSummary(R.string.downloads_storage_ask_summary);
|
||||
}
|
||||
|
||||
if (hasInvalidPath(downloadPathVideoPreference)
|
||||
|| hasInvalidPath(downloadPathAudioPreference)) {
|
||||
updatePreferencesSummary();
|
||||
@ -76,7 +87,7 @@ public class DownloadSettingsFragment extends BasePreferenceFragment {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAttach(final Context context) {
|
||||
public void onAttach(@NonNull final Context context) {
|
||||
super.onAttach(context);
|
||||
ctx = context;
|
||||
}
|
||||
@ -177,8 +188,14 @@ public class DownloadSettingsFragment extends BasePreferenceFragment {
|
||||
final int request;
|
||||
|
||||
if (key.equals(storageUseSafPreference)) {
|
||||
Toast.makeText(getContext(), R.string.download_choose_new_path,
|
||||
Toast.LENGTH_LONG).show();
|
||||
if (!NewPipeSettings.useStorageAccessFramework(ctx)) {
|
||||
NewPipeSettings.saveDefaultVideoDownloadDirectory(ctx);
|
||||
NewPipeSettings.saveDefaultAudioDownloadDirectory(ctx);
|
||||
} else {
|
||||
defaultPreferences.edit().putString(downloadPathVideoPreference, null)
|
||||
.putString(downloadPathAudioPreference, null).apply();
|
||||
}
|
||||
updatePreferencesSummary();
|
||||
return true;
|
||||
} else if (key.equals(downloadPathVideoPreference)) {
|
||||
request = REQUEST_DOWNLOAD_VIDEO_PATH;
|
||||
@ -188,22 +205,7 @@ public class DownloadSettingsFragment extends BasePreferenceFragment {
|
||||
return super.onPreferenceTreeClick(preference);
|
||||
}
|
||||
|
||||
final Intent i;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP
|
||||
&& NewPipeSettings.useStorageAccessFramework(ctx)) {
|
||||
i = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
||||
.putExtra("android.content.extra.SHOW_ADVANCED", true)
|
||||
.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
|
||||
| StoredDirectoryHelper.PERMISSION_FLAGS);
|
||||
} else {
|
||||
i = new Intent(getActivity(), FilePickerActivityHelper.class)
|
||||
.putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false)
|
||||
.putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, true)
|
||||
.putExtra(FilePickerActivityHelper.EXTRA_MODE,
|
||||
FilePickerActivityHelper.MODE_DIR);
|
||||
}
|
||||
|
||||
startActivityForResult(i, request);
|
||||
startActivityForResult(StoredDirectoryHelper.getPicker(ctx), request);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
@ -2,16 +2,20 @@ package org.schabi.newpipe.settings;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.os.Build;
|
||||
import android.os.Environment;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.preference.PreferenceManager;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.util.DeviceUtils;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
|
||||
|
||||
/*
|
||||
* Created by k3b on 07.01.2016.
|
||||
*
|
||||
@ -65,32 +69,36 @@ public final class NewPipeSettings {
|
||||
PreferenceManager.setDefaultValues(context, R.xml.update_settings, true);
|
||||
PreferenceManager.setDefaultValues(context, R.xml.debug_settings, true);
|
||||
|
||||
getVideoDownloadFolder(context);
|
||||
getAudioDownloadFolder(context);
|
||||
saveDefaultVideoDownloadDirectory(context);
|
||||
saveDefaultAudioDownloadDirectory(context);
|
||||
|
||||
SettingMigrations.initMigrations(context, isFirstRun);
|
||||
}
|
||||
|
||||
private static void getVideoDownloadFolder(final Context context) {
|
||||
getDir(context, R.string.download_path_video_key, Environment.DIRECTORY_MOVIES);
|
||||
static void saveDefaultVideoDownloadDirectory(final Context context) {
|
||||
saveDefaultDirectory(context, R.string.download_path_video_key,
|
||||
Environment.DIRECTORY_MOVIES);
|
||||
}
|
||||
|
||||
private static void getAudioDownloadFolder(final Context context) {
|
||||
getDir(context, R.string.download_path_audio_key, Environment.DIRECTORY_MUSIC);
|
||||
static void saveDefaultAudioDownloadDirectory(final Context context) {
|
||||
saveDefaultDirectory(context, R.string.download_path_audio_key,
|
||||
Environment.DIRECTORY_MUSIC);
|
||||
}
|
||||
|
||||
private static void getDir(final Context context, final int keyID,
|
||||
final String defaultDirectoryName) {
|
||||
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
|
||||
final String key = context.getString(keyID);
|
||||
final String downloadPath = prefs.getString(key, null);
|
||||
if ((downloadPath != null) && (!downloadPath.isEmpty())) {
|
||||
return;
|
||||
private static void saveDefaultDirectory(final Context context, final int keyID,
|
||||
final String defaultDirectoryName) {
|
||||
if (!useStorageAccessFramework(context)) {
|
||||
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
|
||||
final String key = context.getString(keyID);
|
||||
final String downloadPath = prefs.getString(key, null);
|
||||
if (!isNullOrEmpty(downloadPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final SharedPreferences.Editor spEditor = prefs.edit();
|
||||
spEditor.putString(key, getNewPipeChildFolderPathForDir(getDir(defaultDirectoryName)));
|
||||
spEditor.apply();
|
||||
}
|
||||
|
||||
final SharedPreferences.Editor spEditor = prefs.edit();
|
||||
spEditor.putString(key, getNewPipeChildFolderPathForDir(getDir(defaultDirectoryName)));
|
||||
spEditor.apply();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@ -103,10 +111,17 @@ public final class NewPipeSettings {
|
||||
}
|
||||
|
||||
public static boolean useStorageAccessFramework(final Context context) {
|
||||
// There's a FireOS bug which prevents SAF open/close dialogs from being confirmed with a
|
||||
// remote (see #6455).
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP || DeviceUtils.isFireTv()) {
|
||||
return false;
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final String key = context.getString(R.string.storage_use_saf);
|
||||
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
|
||||
|
||||
return prefs.getBoolean(key, false);
|
||||
return prefs.getBoolean(key, true);
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -139,7 +139,8 @@ public class PeertubeInstanceListFragment extends Fragment {
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) {
|
||||
public void onCreateOptionsMenu(@NonNull final Menu menu,
|
||||
@NonNull final MenuInflater inflater) {
|
||||
super.onCreateOptionsMenu(menu, inflater);
|
||||
|
||||
final MenuItem restoreItem = menu
|
||||
@ -279,7 +280,7 @@ public class PeertubeInstanceListFragment extends Fragment {
|
||||
return new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN,
|
||||
ItemTouchHelper.START | ItemTouchHelper.END) {
|
||||
@Override
|
||||
public int interpolateOutOfBoundsScroll(final RecyclerView recyclerView,
|
||||
public int interpolateOutOfBoundsScroll(@NonNull final RecyclerView recyclerView,
|
||||
final int viewSize,
|
||||
final int viewSizeOutOfBounds,
|
||||
final int totalSize,
|
||||
@ -292,9 +293,9 @@ public class PeertubeInstanceListFragment extends Fragment {
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onMove(final RecyclerView recyclerView,
|
||||
final RecyclerView.ViewHolder source,
|
||||
final RecyclerView.ViewHolder target) {
|
||||
public boolean onMove(@NonNull final RecyclerView recyclerView,
|
||||
@NonNull final RecyclerView.ViewHolder source,
|
||||
@NonNull final RecyclerView.ViewHolder target) {
|
||||
if (source.getItemViewType() != target.getItemViewType()
|
||||
|| instanceListAdapter == null) {
|
||||
return false;
|
||||
@ -317,7 +318,8 @@ public class PeertubeInstanceListFragment extends Fragment {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSwiped(final RecyclerView.ViewHolder viewHolder, final int swipeDir) {
|
||||
public void onSwiped(@NonNull final RecyclerView.ViewHolder viewHolder,
|
||||
final int swipeDir) {
|
||||
final int position = viewHolder.getAdapterPosition();
|
||||
// do not allow swiping the selected instance
|
||||
if (instanceList.get(position).getUrl().equals(selectedInstance.getUrl())) {
|
||||
|
@ -8,6 +8,7 @@ import android.view.ViewGroup;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.content.res.AppCompatResources;
|
||||
import androidx.fragment.app.DialogFragment;
|
||||
@ -92,7 +93,7 @@ public class SelectKioskFragment extends DialogFragment {
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
public void onCancel(final DialogInterface dialogInterface) {
|
||||
public void onCancel(@NonNull final DialogInterface dialogInterface) {
|
||||
super.onCancel(dialogInterface);
|
||||
if (onCancelListener != null) {
|
||||
onCancelListener.onCancel();
|
||||
@ -138,6 +139,7 @@ public class SelectKioskFragment extends DialogFragment {
|
||||
return kioskList.size();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public SelectKioskItemHolder onCreateViewHolder(final ViewGroup parent, final int type) {
|
||||
final View item = LayoutInflater.from(parent.getContext())
|
||||
.inflate(R.layout.select_kiosk_item, parent, false);
|
||||
|
@ -2,6 +2,7 @@ package org.schabi.newpipe.settings;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.os.Build;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.preference.PreferenceManager;
|
||||
@ -10,6 +11,7 @@ import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.error.ErrorActivity;
|
||||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.util.DeviceUtils;
|
||||
|
||||
import static org.schabi.newpipe.MainActivity.DEBUG;
|
||||
|
||||
@ -18,7 +20,7 @@ public final class SettingMigrations {
|
||||
/**
|
||||
* Version number for preferences. Must be incremented every time a migration is necessary.
|
||||
*/
|
||||
public static final int VERSION = 2;
|
||||
public static final int VERSION = 3;
|
||||
private static SharedPreferences sp;
|
||||
|
||||
public static final Migration MIGRATION_0_1 = new Migration(0, 1) {
|
||||
@ -54,6 +56,22 @@ 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.
|
||||
* <p>
|
||||
@ -62,7 +80,8 @@ public final class SettingMigrations {
|
||||
*/
|
||||
private static final Migration[] SETTING_MIGRATIONS = {
|
||||
MIGRATION_0_1,
|
||||
MIGRATION_1_2
|
||||
MIGRATION_1_2,
|
||||
MIGRATION_2_3
|
||||
};
|
||||
|
||||
|
||||
|
@ -1,6 +1,5 @@
|
||||
package org.schabi.newpipe.settings;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Bundle;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
@ -41,11 +40,6 @@ import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
|
||||
public class SettingsActivity extends AppCompatActivity
|
||||
implements BasePreferenceFragment.OnPreferenceStartFragmentCallback {
|
||||
|
||||
public static void initSettings(final Context context) {
|
||||
NewPipeSettings.initSettings(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(final Bundle savedInstanceBundle) {
|
||||
setTheme(ThemeHelper.getSettingsThemeStyle(this));
|
||||
|
@ -106,7 +106,8 @@ public class ChooseTabsFragment extends Fragment {
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) {
|
||||
public void onCreateOptionsMenu(@NonNull final Menu menu,
|
||||
@NonNull final MenuInflater inflater) {
|
||||
super.onCreateOptionsMenu(menu, inflater);
|
||||
|
||||
final MenuItem restoreItem = menu.add(Menu.NONE, MENU_ITEM_RESTORE_ID, Menu.NONE,
|
||||
@ -192,13 +193,13 @@ public class ChooseTabsFragment extends Fragment {
|
||||
final SelectKioskFragment selectKioskFragment = new SelectKioskFragment();
|
||||
selectKioskFragment.setOnSelectedListener((serviceId, kioskId, kioskName) ->
|
||||
addTab(new Tab.KioskTab(serviceId, kioskId)));
|
||||
selectKioskFragment.show(requireFragmentManager(), "select_kiosk");
|
||||
selectKioskFragment.show(getParentFragmentManager(), "select_kiosk");
|
||||
return;
|
||||
case CHANNEL:
|
||||
final SelectChannelFragment selectChannelFragment = new SelectChannelFragment();
|
||||
selectChannelFragment.setOnSelectedListener((serviceId, url, name) ->
|
||||
addTab(new Tab.ChannelTab(serviceId, url, name)));
|
||||
selectChannelFragment.show(requireFragmentManager(), "select_channel");
|
||||
selectChannelFragment.show(getParentFragmentManager(), "select_channel");
|
||||
return;
|
||||
case PLAYLIST:
|
||||
final SelectPlaylistFragment selectPlaylistFragment = new SelectPlaylistFragment();
|
||||
@ -215,7 +216,7 @@ public class ChooseTabsFragment extends Fragment {
|
||||
addTab(new Tab.PlaylistTab(serviceId, url, name));
|
||||
}
|
||||
});
|
||||
selectPlaylistFragment.show(requireFragmentManager(), "select_playlist");
|
||||
selectPlaylistFragment.show(getParentFragmentManager(), "select_playlist");
|
||||
return;
|
||||
default:
|
||||
addTab(type.getTab());
|
||||
@ -277,7 +278,7 @@ public class ChooseTabsFragment extends Fragment {
|
||||
return new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN,
|
||||
ItemTouchHelper.START | ItemTouchHelper.END) {
|
||||
@Override
|
||||
public int interpolateOutOfBoundsScroll(final RecyclerView recyclerView,
|
||||
public int interpolateOutOfBoundsScroll(@NonNull final RecyclerView recyclerView,
|
||||
final int viewSize,
|
||||
final int viewSizeOutOfBounds,
|
||||
final int totalSize,
|
||||
@ -290,9 +291,9 @@ public class ChooseTabsFragment extends Fragment {
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onMove(final RecyclerView recyclerView,
|
||||
final RecyclerView.ViewHolder source,
|
||||
final RecyclerView.ViewHolder target) {
|
||||
public boolean onMove(@NonNull final RecyclerView recyclerView,
|
||||
@NonNull final RecyclerView.ViewHolder source,
|
||||
@NonNull final RecyclerView.ViewHolder target) {
|
||||
if (source.getItemViewType() != target.getItemViewType()
|
||||
|| selectedTabsAdapter == null) {
|
||||
return false;
|
||||
@ -315,7 +316,8 @@ public class ChooseTabsFragment extends Fragment {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSwiped(final RecyclerView.ViewHolder viewHolder, final int swipeDir) {
|
||||
public void onSwiped(@NonNull final RecyclerView.ViewHolder viewHolder,
|
||||
final int swipeDir) {
|
||||
final int position = viewHolder.getAdapterPosition();
|
||||
tabList.remove(position);
|
||||
selectedTabsAdapter.notifyItemRemoved(position);
|
||||
|
@ -112,12 +112,16 @@ public abstract class Tab {
|
||||
|
||||
@Override
|
||||
public boolean equals(final Object obj) {
|
||||
if (obj == this) {
|
||||
return true;
|
||||
if (!(obj instanceof Tab)) {
|
||||
return false;
|
||||
}
|
||||
final Tab other = (Tab) obj;
|
||||
return getTabId() == other.getTabId();
|
||||
}
|
||||
|
||||
return obj instanceof Tab && obj.getClass() == this.getClass()
|
||||
&& ((Tab) obj).getTabId() == this.getTabId();
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hashCode(getTabId());
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
@ -358,8 +362,18 @@ public abstract class Tab {
|
||||
|
||||
@Override
|
||||
public boolean equals(final Object obj) {
|
||||
return super.equals(obj) && kioskServiceId == ((KioskTab) obj).kioskServiceId
|
||||
&& Objects.equals(kioskId, ((KioskTab) obj).kioskId);
|
||||
if (!(obj instanceof KioskTab)) {
|
||||
return false;
|
||||
}
|
||||
final KioskTab other = (KioskTab) obj;
|
||||
return super.equals(obj)
|
||||
&& kioskServiceId == other.kioskServiceId
|
||||
&& kioskId.equals(other.kioskId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(getTabId(), kioskServiceId, kioskId);
|
||||
}
|
||||
|
||||
public int getKioskServiceId() {
|
||||
@ -432,9 +446,19 @@ public abstract class Tab {
|
||||
|
||||
@Override
|
||||
public boolean equals(final Object obj) {
|
||||
return super.equals(obj) && channelServiceId == ((ChannelTab) obj).channelServiceId
|
||||
&& Objects.equals(channelUrl, ((ChannelTab) obj).channelUrl)
|
||||
&& Objects.equals(channelName, ((ChannelTab) obj).channelName);
|
||||
if (!(obj instanceof ChannelTab)) {
|
||||
return false;
|
||||
}
|
||||
final ChannelTab other = (ChannelTab) obj;
|
||||
return super.equals(obj)
|
||||
&& channelServiceId == other.channelServiceId
|
||||
&& channelUrl.equals(other.channelName)
|
||||
&& channelName.equals(other.channelName);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(getTabId(), channelServiceId, channelUrl, channelName);
|
||||
}
|
||||
|
||||
public int getChannelServiceId() {
|
||||
@ -576,15 +600,30 @@ public abstract class Tab {
|
||||
|
||||
@Override
|
||||
public boolean equals(final Object obj) {
|
||||
if (!(super.equals(obj)
|
||||
&& Objects.equals(playlistType, ((PlaylistTab) obj).playlistType)
|
||||
&& Objects.equals(playlistName, ((PlaylistTab) obj).playlistName))) {
|
||||
return false; // base objects are different
|
||||
if (!(obj instanceof PlaylistTab)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (playlistId == ((PlaylistTab) obj).playlistId) // local
|
||||
|| (playlistServiceId == ((PlaylistTab) obj).playlistServiceId // remote
|
||||
&& Objects.equals(playlistUrl, ((PlaylistTab) obj).playlistUrl));
|
||||
final PlaylistTab other = (PlaylistTab) obj;
|
||||
|
||||
return super.equals(obj)
|
||||
&& playlistServiceId == other.playlistServiceId // Remote
|
||||
&& playlistId == other.playlistId // Local
|
||||
&& playlistUrl.equals(other.playlistUrl)
|
||||
&& playlistName.equals(other.playlistName)
|
||||
&& playlistType == other.playlistType;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(
|
||||
getTabId(),
|
||||
playlistServiceId,
|
||||
playlistId,
|
||||
playlistUrl,
|
||||
playlistName,
|
||||
playlistType
|
||||
);
|
||||
}
|
||||
|
||||
public int getPlaylistServiceId() {
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -1,12 +1,20 @@
|
||||
package org.schabi.newpipe.streams.io;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.io.Flushable;
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Based on C#'s Stream class.
|
||||
* Based on C#'s Stream class. SharpStream is a wrapper around the 2 different APIs for SAF
|
||||
* ({@link us.shandian.giga.io.FileStreamSAF}) and non-SAF ({@link us.shandian.giga.io.FileStream}).
|
||||
* It has both input and output like in C#, while in Java those are usually different classes.
|
||||
* {@link SharpInputStream} and {@link SharpOutputStream} are simple classes that wrap
|
||||
* {@link SharpStream} and extend respectively {@link java.io.InputStream} and
|
||||
* {@link java.io.OutputStream}, since unfortunately a class can only extend one class, so that a
|
||||
* sharp stream can be used with built-in Java stuff that supports {@link java.io.InputStream}
|
||||
* or {@link java.io.OutputStream}.
|
||||
*/
|
||||
public abstract class SharpStream implements Closeable {
|
||||
public abstract class SharpStream implements Closeable, Flushable {
|
||||
public abstract int read() throws IOException;
|
||||
|
||||
public abstract int read(byte[] buffer) throws IOException;
|
||||
|
@ -1,6 +1,5 @@
|
||||
package us.shandian.giga.io;
|
||||
package org.schabi.newpipe.streams.io;
|
||||
|
||||
import android.annotation.TargetApi;
|
||||
import android.content.ContentResolver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
@ -13,6 +12,9 @@ import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.documentfile.provider.DocumentFile;
|
||||
|
||||
import org.schabi.newpipe.settings.NewPipeSettings;
|
||||
import org.schabi.newpipe.util.FilePickerActivityHelper;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
@ -21,10 +23,11 @@ import java.util.Collections;
|
||||
|
||||
import static android.provider.DocumentsContract.Document.COLUMN_DISPLAY_NAME;
|
||||
import static android.provider.DocumentsContract.Root.COLUMN_DOCUMENT_ID;
|
||||
|
||||
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
|
||||
|
||||
public class StoredDirectoryHelper {
|
||||
public final static int PERMISSION_FLAGS = Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION;
|
||||
public static final int PERMISSION_FLAGS = Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION;
|
||||
|
||||
private File ioTree;
|
||||
private DocumentFile docTree;
|
||||
@ -33,7 +36,8 @@ public class StoredDirectoryHelper {
|
||||
|
||||
private final String tag;
|
||||
|
||||
public StoredDirectoryHelper(@NonNull Context context, @NonNull Uri path, String tag) throws IOException {
|
||||
public StoredDirectoryHelper(@NonNull final Context context, @NonNull final Uri path,
|
||||
final String tag) throws IOException {
|
||||
this.tag = tag;
|
||||
|
||||
if (ContentResolver.SCHEME_FILE.equalsIgnoreCase(path.getScheme())) {
|
||||
@ -45,51 +49,49 @@ public class StoredDirectoryHelper {
|
||||
|
||||
try {
|
||||
this.context.getContentResolver().takePersistableUriPermission(path, PERMISSION_FLAGS);
|
||||
} catch (Exception e) {
|
||||
} catch (final Exception e) {
|
||||
throw new IOException(e);
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP)
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
|
||||
throw new IOException("Storage Access Framework with Directory API is not available");
|
||||
}
|
||||
|
||||
this.docTree = DocumentFile.fromTreeUri(context, path);
|
||||
|
||||
if (this.docTree == null)
|
||||
if (this.docTree == null) {
|
||||
throw new IOException("Failed to create the tree from Uri");
|
||||
}
|
||||
}
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.KITKAT)
|
||||
public StoredDirectoryHelper(@NonNull URI location, String tag) {
|
||||
ioTree = new File(location);
|
||||
this.tag = tag;
|
||||
}
|
||||
|
||||
public StoredFileHelper createFile(String filename, String mime) {
|
||||
public StoredFileHelper createFile(final String filename, final String mime) {
|
||||
return createFile(filename, mime, false);
|
||||
}
|
||||
|
||||
public StoredFileHelper createUniqueFile(String name, String mime) {
|
||||
ArrayList<String> matches = new ArrayList<>();
|
||||
String[] filename = splitFilename(name);
|
||||
String lcFilename = filename[0].toLowerCase();
|
||||
public StoredFileHelper createUniqueFile(final String name, final String mime) {
|
||||
final ArrayList<String> matches = new ArrayList<>();
|
||||
final String[] filename = splitFilename(name);
|
||||
final String lcFilename = filename[0].toLowerCase();
|
||||
|
||||
if (docTree == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
|
||||
for (File file : ioTree.listFiles())
|
||||
for (final File file : ioTree.listFiles()) {
|
||||
addIfStartWith(matches, lcFilename, file.getName());
|
||||
}
|
||||
} else {
|
||||
// warning: SAF file listing is very slow
|
||||
Uri docTreeChildren = DocumentsContract.buildChildDocumentsUriUsingTree(
|
||||
docTree.getUri(), DocumentsContract.getDocumentId(docTree.getUri())
|
||||
);
|
||||
final Uri docTreeChildren = DocumentsContract.buildChildDocumentsUriUsingTree(
|
||||
docTree.getUri(), DocumentsContract.getDocumentId(docTree.getUri()));
|
||||
|
||||
String[] projection = {COLUMN_DISPLAY_NAME};
|
||||
String selection = "(LOWER(" + COLUMN_DISPLAY_NAME + ") LIKE ?%";
|
||||
ContentResolver cr = context.getContentResolver();
|
||||
final String[] projection = new String[]{COLUMN_DISPLAY_NAME};
|
||||
final String selection = "(LOWER(" + COLUMN_DISPLAY_NAME + ") LIKE ?%";
|
||||
final ContentResolver cr = context.getContentResolver();
|
||||
|
||||
try (Cursor cursor = cr.query(docTreeChildren, projection, selection, new String[]{lcFilename}, null)) {
|
||||
try (Cursor cursor = cr.query(docTreeChildren, projection, selection,
|
||||
new String[]{lcFilename}, null)) {
|
||||
if (cursor != null) {
|
||||
while (cursor.moveToNext())
|
||||
while (cursor.moveToNext()) {
|
||||
addIfStartWith(matches, lcFilename, cursor.getString(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -99,7 +101,7 @@ public class StoredDirectoryHelper {
|
||||
} else {
|
||||
// check if the filename is in use
|
||||
String lcName = name.toLowerCase();
|
||||
for (String testName : matches) {
|
||||
for (final String testName : matches) {
|
||||
if (testName.equals(lcName)) {
|
||||
lcName = null;
|
||||
break;
|
||||
@ -107,28 +109,34 @@ public class StoredDirectoryHelper {
|
||||
}
|
||||
|
||||
// check if not in use
|
||||
if (lcName != null) return createFile(name, mime, true);
|
||||
if (lcName != null) {
|
||||
return createFile(name, mime, true);
|
||||
}
|
||||
}
|
||||
|
||||
Collections.sort(matches, String::compareTo);
|
||||
|
||||
for (int i = 1; i < 1000; i++) {
|
||||
if (Collections.binarySearch(matches, makeFileName(lcFilename, i, filename[1])) < 0)
|
||||
if (Collections.binarySearch(matches, makeFileName(lcFilename, i, filename[1])) < 0) {
|
||||
return createFile(makeFileName(filename[0], i, filename[1]), mime, true);
|
||||
}
|
||||
}
|
||||
|
||||
return createFile(String.valueOf(System.currentTimeMillis()).concat(filename[1]), mime, false);
|
||||
return createFile(String.valueOf(System.currentTimeMillis()).concat(filename[1]), mime,
|
||||
false);
|
||||
}
|
||||
|
||||
private StoredFileHelper createFile(String filename, String mime, boolean safe) {
|
||||
StoredFileHelper storage;
|
||||
private StoredFileHelper createFile(final String filename, final String mime,
|
||||
final boolean safe) {
|
||||
final StoredFileHelper storage;
|
||||
|
||||
try {
|
||||
if (docTree == null)
|
||||
if (docTree == null) {
|
||||
storage = new StoredFileHelper(ioTree, filename, mime);
|
||||
else
|
||||
} else {
|
||||
storage = new StoredFileHelper(context, docTree, filename, mime, safe);
|
||||
} catch (IOException e) {
|
||||
}
|
||||
} catch (final IOException e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -146,7 +154,7 @@ public class StoredDirectoryHelper {
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whatever if is possible access using the {@code java.io} API
|
||||
* Indicates whether it's using the {@code java.io} API.
|
||||
*
|
||||
* @return {@code true} for Java I/O API, otherwise, {@code false} for Storage Access Framework
|
||||
*/
|
||||
@ -169,7 +177,9 @@ public class StoredDirectoryHelper {
|
||||
return ioTree.exists() || ioTree.mkdirs();
|
||||
}
|
||||
|
||||
if (docTree.exists()) return true;
|
||||
if (docTree.exists()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
DocumentFile parent;
|
||||
@ -177,14 +187,18 @@ public class StoredDirectoryHelper {
|
||||
|
||||
while (true) {
|
||||
parent = docTree.getParentFile();
|
||||
if (parent == null || child == null) break;
|
||||
if (parent.exists()) return true;
|
||||
if (parent == null || child == null) {
|
||||
break;
|
||||
}
|
||||
if (parent.exists()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
parent.createDirectory(child);
|
||||
|
||||
child = parent.getName();// for the next iteration
|
||||
child = parent.getName(); // for the next iteration
|
||||
}
|
||||
} catch (Exception e) {
|
||||
} catch (final Exception ignored) {
|
||||
// no more parent directories or unsupported by the storage provider
|
||||
}
|
||||
|
||||
@ -195,13 +209,13 @@ public class StoredDirectoryHelper {
|
||||
return tag;
|
||||
}
|
||||
|
||||
public Uri findFile(String filename) {
|
||||
public Uri findFile(final String filename) {
|
||||
if (docTree == null) {
|
||||
File res = new File(ioTree, filename);
|
||||
final File res = new File(ioTree, filename);
|
||||
return res.exists() ? Uri.fromFile(res) : null;
|
||||
}
|
||||
|
||||
DocumentFile res = findFileSAFHelper(context, docTree, filename);
|
||||
final DocumentFile res = findFileSAFHelper(context, docTree, filename);
|
||||
return res == null ? null : res.getUri();
|
||||
}
|
||||
|
||||
@ -209,82 +223,115 @@ public class StoredDirectoryHelper {
|
||||
return docTree == null ? ioTree.canWrite() : docTree.canWrite();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {@code false} if the storage is direct, or the SAF storage is valid; {@code true} if
|
||||
* SAF access to this SAF storage is denied (e.g. the user clicked on {@code Android settings ->
|
||||
* Apps & notifications -> NewPipe -> Storage & cache -> Clear access});
|
||||
*/
|
||||
public boolean isInvalidSafStorage() {
|
||||
return docTree != null && docTree.getName() == null;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public String toString() {
|
||||
return (docTree == null ? Uri.fromFile(ioTree) : docTree.getUri()).toString();
|
||||
}
|
||||
|
||||
|
||||
////////////////////
|
||||
// Utils
|
||||
///////////////////
|
||||
|
||||
private static void addIfStartWith(ArrayList<String> list, @NonNull String base, String str) {
|
||||
if (str == null || str.isEmpty()) return;
|
||||
str = str.toLowerCase();
|
||||
if (str.startsWith(base)) list.add(str);
|
||||
private static void addIfStartWith(final ArrayList<String> list, @NonNull final String base,
|
||||
final String str) {
|
||||
if (isNullOrEmpty(str)) {
|
||||
return;
|
||||
}
|
||||
final String lowerStr = str.toLowerCase();
|
||||
if (lowerStr.startsWith(base)) {
|
||||
list.add(lowerStr);
|
||||
}
|
||||
}
|
||||
|
||||
private static String[] splitFilename(@NonNull String filename) {
|
||||
int dotIndex = filename.lastIndexOf('.');
|
||||
private static String[] splitFilename(@NonNull final String filename) {
|
||||
final int dotIndex = filename.lastIndexOf('.');
|
||||
|
||||
if (dotIndex < 0 || (dotIndex == filename.length() - 1))
|
||||
if (dotIndex < 0 || (dotIndex == filename.length() - 1)) {
|
||||
return new String[]{filename, ""};
|
||||
}
|
||||
|
||||
return new String[]{filename.substring(0, dotIndex), filename.substring(dotIndex)};
|
||||
}
|
||||
|
||||
private static String makeFileName(String name, int idx, String ext) {
|
||||
private static String makeFileName(final String name, final int idx, final String ext) {
|
||||
return name.concat(" (").concat(String.valueOf(idx)).concat(")").concat(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fast (but not enough) file/directory finder under the storage access framework
|
||||
* Fast (but not enough) file/directory finder under the storage access framework.
|
||||
*
|
||||
* @param context The context
|
||||
* @param tree Directory where search
|
||||
* @param filename Target filename
|
||||
* @return A {@link DocumentFile} contain the reference, otherwise, null
|
||||
*/
|
||||
static DocumentFile findFileSAFHelper(@Nullable Context context, DocumentFile tree, String filename) {
|
||||
static DocumentFile findFileSAFHelper(@Nullable final Context context, final DocumentFile tree,
|
||||
final String filename) {
|
||||
if (context == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
|
||||
return tree.findFile(filename);// warning: this is very slow
|
||||
return tree.findFile(filename); // warning: this is very slow
|
||||
}
|
||||
|
||||
if (!tree.canRead()) return null;// missing read permission
|
||||
if (!tree.canRead()) {
|
||||
return null; // missing read permission
|
||||
}
|
||||
|
||||
final int name = 0;
|
||||
final int documentId = 1;
|
||||
|
||||
// LOWER() SQL function is not supported
|
||||
String selection = COLUMN_DISPLAY_NAME + " = ?";
|
||||
//String selection = COLUMN_DISPLAY_NAME + " LIKE ?%";
|
||||
final String selection = COLUMN_DISPLAY_NAME + " = ?";
|
||||
//final String selection = COLUMN_DISPLAY_NAME + " LIKE ?%";
|
||||
|
||||
Uri childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(
|
||||
tree.getUri(), DocumentsContract.getDocumentId(tree.getUri())
|
||||
);
|
||||
String[] projection = {COLUMN_DISPLAY_NAME, COLUMN_DOCUMENT_ID};
|
||||
ContentResolver contentResolver = context.getContentResolver();
|
||||
final Uri childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(tree.getUri(),
|
||||
DocumentsContract.getDocumentId(tree.getUri()));
|
||||
final String[] projection = {COLUMN_DISPLAY_NAME, COLUMN_DOCUMENT_ID};
|
||||
final ContentResolver contentResolver = context.getContentResolver();
|
||||
|
||||
filename = filename.toLowerCase();
|
||||
final String lowerFilename = filename.toLowerCase();
|
||||
|
||||
try (Cursor cursor = contentResolver.query(childrenUri, projection, selection, new String[]{filename}, null)) {
|
||||
if (cursor == null) return null;
|
||||
try (Cursor cursor = contentResolver.query(childrenUri, projection, selection,
|
||||
new String[]{lowerFilename}, null)) {
|
||||
if (cursor == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
while (cursor.moveToNext()) {
|
||||
if (cursor.isNull(name) || !cursor.getString(name).toLowerCase().startsWith(filename))
|
||||
if (cursor.isNull(name)
|
||||
|| !cursor.getString(name).toLowerCase().startsWith(lowerFilename)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return DocumentFile.fromSingleUri(
|
||||
context, DocumentsContract.buildDocumentUriUsingTree(
|
||||
tree.getUri(), cursor.getString(documentId)
|
||||
)
|
||||
);
|
||||
return DocumentFile.fromSingleUri(context,
|
||||
DocumentsContract.buildDocumentUriUsingTree(tree.getUri(),
|
||||
cursor.getString(documentId)));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static Intent getPicker(final Context ctx) {
|
||||
if (NewPipeSettings.useStorageAccessFramework(ctx)) {
|
||||
return new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
||||
.putExtra("android.content.extra.SHOW_ADVANCED", true)
|
||||
.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
|
||||
| StoredDirectoryHelper.PERMISSION_FLAGS);
|
||||
} else {
|
||||
return new Intent(ctx, FilePickerActivityHelper.class)
|
||||
.putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false)
|
||||
.putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, true)
|
||||
.putExtra(FilePickerActivityHelper.EXTRA_MODE,
|
||||
FilePickerActivityHelper.MODE_DIR);
|
||||
}
|
||||
}
|
||||
}
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
@ -1,6 +1,5 @@
|
||||
package org.schabi.newpipe.util;
|
||||
|
||||
import android.content.Context;
|
||||
import android.text.Layout;
|
||||
import android.text.Selection;
|
||||
import android.text.Spannable;
|
||||
@ -11,27 +10,14 @@ import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.schabi.newpipe.extractor.NewPipe;
|
||||
import org.schabi.newpipe.extractor.StreamingService;
|
||||
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
||||
import org.schabi.newpipe.extractor.exceptions.ParsingException;
|
||||
import org.schabi.newpipe.extractor.linkhandler.LinkHandlerFactory;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||
import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||
import org.schabi.newpipe.util.external_communication.InternalUrlsHandler;
|
||||
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||
|
||||
public class CommentTextOnTouchListener implements View.OnTouchListener {
|
||||
public static final CommentTextOnTouchListener INSTANCE = new CommentTextOnTouchListener();
|
||||
|
||||
private static final Pattern TIMESTAMP_PATTERN = Pattern.compile("(.*)#timestamp=(\\d+)");
|
||||
|
||||
@Override
|
||||
public boolean onTouch(final View v, final MotionEvent event) {
|
||||
if (!(v instanceof TextView)) {
|
||||
@ -64,13 +50,12 @@ public class CommentTextOnTouchListener implements View.OnTouchListener {
|
||||
|
||||
if (link.length != 0) {
|
||||
if (action == MotionEvent.ACTION_UP) {
|
||||
boolean handled = false;
|
||||
if (link[0] instanceof URLSpan) {
|
||||
handled = handleUrl(v.getContext(), (URLSpan) link[0]);
|
||||
}
|
||||
if (!handled) {
|
||||
ShareUtils.openUrlInBrowser(v.getContext(),
|
||||
((URLSpan) link[0]).getURL(), false);
|
||||
final String url = ((URLSpan) link[0]).getURL();
|
||||
if (!InternalUrlsHandler.handleUrlCommentsTimestamp(
|
||||
new CompositeDisposable(), v.getContext(), url)) {
|
||||
ShareUtils.openUrlInBrowser(v.getContext(), url, false);
|
||||
}
|
||||
}
|
||||
} else if (action == MotionEvent.ACTION_DOWN) {
|
||||
Selection.setSelection(buffer,
|
||||
@ -83,52 +68,4 @@ public class CommentTextOnTouchListener implements View.OnTouchListener {
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
@ -12,13 +12,16 @@ import android.view.KeyEvent;
|
||||
import androidx.annotation.Dimension;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.preference.PreferenceManager;
|
||||
|
||||
import org.schabi.newpipe.App;
|
||||
import org.schabi.newpipe.R;
|
||||
|
||||
public final class DeviceUtils {
|
||||
|
||||
private static final String AMAZON_FEATURE_FIRE_TV = "amazon.hardware.fire_tv";
|
||||
private static Boolean isTV = null;
|
||||
private static Boolean isFireTV = null;
|
||||
|
||||
/*
|
||||
* Devices that do not support media tunneling
|
||||
@ -33,6 +36,16 @@ public final class DeviceUtils {
|
||||
private DeviceUtils() {
|
||||
}
|
||||
|
||||
public static boolean isFireTv() {
|
||||
if (isFireTV != null) {
|
||||
return isFireTV;
|
||||
}
|
||||
|
||||
isFireTV =
|
||||
App.getApp().getPackageManager().hasSystemFeature(AMAZON_FEATURE_FIRE_TV);
|
||||
return isFireTV;
|
||||
}
|
||||
|
||||
public static boolean isTv(final Context context) {
|
||||
if (isTV != null) {
|
||||
return isTV;
|
||||
@ -43,7 +56,7 @@ public final class DeviceUtils {
|
||||
// from doc: https://developer.android.com/training/tv/start/hardware.html#runtime-check
|
||||
boolean isTv = ContextCompat.getSystemService(context, UiModeManager.class)
|
||||
.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION
|
||||
|| pm.hasSystemFeature(AMAZON_FEATURE_FIRE_TV)
|
||||
|| isFireTv()
|
||||
|| pm.hasSystemFeature(PackageManager.FEATURE_TELEVISION);
|
||||
|
||||
// from https://stackoverflow.com/a/58932366
|
||||
@ -65,10 +78,18 @@ public final class DeviceUtils {
|
||||
}
|
||||
|
||||
public static boolean isTablet(@NonNull final Context context) {
|
||||
return (context
|
||||
.getResources()
|
||||
.getConfiguration().screenLayout & Configuration.SCREENLAYOUT_SIZE_MASK)
|
||||
>= Configuration.SCREENLAYOUT_SIZE_LARGE;
|
||||
final String tabletModeSetting = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
.getString(context.getString(R.string.tablet_mode_key), "");
|
||||
|
||||
if (tabletModeSetting.equals(context.getString(R.string.tablet_mode_on_key))) {
|
||||
return true;
|
||||
} else if (tabletModeSetting.equals(context.getString(R.string.tablet_mode_off_key))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// else automatically determine whether we are in a tablet or not
|
||||
return (context.getResources().getConfiguration().screenLayout
|
||||
& Configuration.SCREENLAYOUT_SIZE_MASK) >= Configuration.SCREENLAYOUT_SIZE_LARGE;
|
||||
}
|
||||
|
||||
public static boolean isConfirmKey(final int keyCode) {
|
||||
|
@ -30,6 +30,7 @@ import androidx.preference.PreferenceManager;
|
||||
|
||||
import org.schabi.newpipe.MainActivity;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.util.external_communication.TextLinkifier;
|
||||
import org.schabi.newpipe.extractor.Info;
|
||||
import org.schabi.newpipe.extractor.InfoItem;
|
||||
import org.schabi.newpipe.extractor.ListExtractor.InfoItemsPage;
|
||||
@ -54,7 +55,7 @@ import java.util.List;
|
||||
|
||||
import io.reactivex.rxjava3.core.Maybe;
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
import io.reactivex.rxjava3.disposables.Disposable;
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||
|
||||
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
|
||||
|
||||
@ -268,18 +269,19 @@ public final class ExtractorHelper {
|
||||
* @param metaInfos a list of meta information, can be null or empty
|
||||
* @param metaInfoTextView the text view in which to show the formatted HTML
|
||||
* @param metaInfoSeparator another view to be shown or hidden accordingly to the text view
|
||||
* @return a disposable to be stored somewhere and disposed when activity/fragment is destroyed
|
||||
* @param disposables disposables created by the method are added here and their lifecycle
|
||||
* should be handled by the calling class
|
||||
*/
|
||||
public static Disposable showMetaInfoInTextView(@Nullable final List<MetaInfo> metaInfos,
|
||||
final TextView metaInfoTextView,
|
||||
final View metaInfoSeparator) {
|
||||
public static void showMetaInfoInTextView(@Nullable final List<MetaInfo> metaInfos,
|
||||
final TextView metaInfoTextView,
|
||||
final View metaInfoSeparator,
|
||||
final CompositeDisposable disposables) {
|
||||
final Context context = metaInfoTextView.getContext();
|
||||
if (metaInfos == null || metaInfos.isEmpty()
|
||||
|| !PreferenceManager.getDefaultSharedPreferences(context).getBoolean(
|
||||
context.getString(R.string.show_meta_info_key), true)) {
|
||||
metaInfoTextView.setVisibility(View.GONE);
|
||||
metaInfoSeparator.setVisibility(View.GONE);
|
||||
return Disposable.empty();
|
||||
|
||||
} else {
|
||||
final StringBuilder stringBuilder = new StringBuilder();
|
||||
@ -310,8 +312,8 @@ public final class ExtractorHelper {
|
||||
}
|
||||
|
||||
metaInfoSeparator.setVisibility(View.VISIBLE);
|
||||
return TextLinkifier.createLinksFromHtmlBlock(context, stringBuilder.toString(),
|
||||
metaInfoTextView, HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_HEADING);
|
||||
TextLinkifier.createLinksFromHtmlBlock(metaInfoTextView, stringBuilder.toString(),
|
||||
HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_HEADING, null, disposables);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
@ -1,7 +1,6 @@
|
||||
package org.schabi.newpipe.util;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.os.Environment;
|
||||
@ -28,25 +27,6 @@ import java.io.File;
|
||||
public class FilePickerActivityHelper extends com.nononsenseapps.filepicker.FilePickerActivity {
|
||||
private CustomFilePickerFragment currentFragment;
|
||||
|
||||
public static Intent chooseSingleFile(@NonNull final Context context) {
|
||||
return new Intent(context, FilePickerActivityHelper.class)
|
||||
.putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false)
|
||||
.putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, false)
|
||||
.putExtra(FilePickerActivityHelper.EXTRA_SINGLE_CLICK, true)
|
||||
.putExtra(FilePickerActivityHelper.EXTRA_MODE, FilePickerActivityHelper.MODE_FILE);
|
||||
}
|
||||
|
||||
public static Intent chooseFileToSave(@NonNull final Context context,
|
||||
@Nullable final String startPath) {
|
||||
return new Intent(context, FilePickerActivityHelper.class)
|
||||
.putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false)
|
||||
.putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, true)
|
||||
.putExtra(FilePickerActivityHelper.EXTRA_ALLOW_EXISTING_FILE, true)
|
||||
.putExtra(FilePickerActivityHelper.EXTRA_START_PATH, startPath)
|
||||
.putExtra(FilePickerActivityHelper.EXTRA_MODE,
|
||||
FilePickerActivityHelper.MODE_NEW_FILE);
|
||||
}
|
||||
|
||||
public static boolean isOwnFileUri(@NonNull final Context context, @NonNull final Uri uri) {
|
||||
if (uri.getAuthority() == null) {
|
||||
return false;
|
||||
|
@ -21,6 +21,7 @@ import androidx.fragment.app.FragmentTransaction;
|
||||
import com.nostra13.universalimageloader.core.ImageLoader;
|
||||
|
||||
import org.schabi.newpipe.MainActivity;
|
||||
import org.schabi.newpipe.NewPipeDatabase;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.RouterActivity;
|
||||
import org.schabi.newpipe.about.AboutActivity;
|
||||
@ -53,10 +54,11 @@ import org.schabi.newpipe.player.helper.PlayerHolder;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
|
||||
import org.schabi.newpipe.settings.SettingsActivity;
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
||||
import static org.schabi.newpipe.util.ShareUtils.installApp;
|
||||
import static org.schabi.newpipe.util.external_communication.ShareUtils.installApp;
|
||||
|
||||
public final class NavigationHelper {
|
||||
public static final String MAIN_FRAGMENT_TAG = "main_fragment_tag";
|
||||
@ -252,7 +254,7 @@ public final class NavigationHelper {
|
||||
|
||||
public static void resolveActivityOrAskToInstall(final Context context, final Intent intent) {
|
||||
if (intent.resolveActivity(context.getPackageManager()) != null) {
|
||||
ShareUtils.openIntentInApp(context, intent);
|
||||
ShareUtils.openIntentInApp(context, intent, false);
|
||||
} else {
|
||||
if (context instanceof Activity) {
|
||||
new AlertDialog.Builder(context)
|
||||
@ -599,4 +601,17 @@ public final class NavigationHelper {
|
||||
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@ -18,6 +18,7 @@ import androidx.core.app.ActivityCompat;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.settings.NewPipeSettings;
|
||||
|
||||
public final class PermissionHelper {
|
||||
public static final int DOWNLOAD_DIALOG_REQUEST_CODE = 778;
|
||||
@ -26,6 +27,10 @@ public final class PermissionHelper {
|
||||
private PermissionHelper() { }
|
||||
|
||||
public static boolean checkStoragePermissions(final Activity activity, final int requestCode) {
|
||||
if (NewPipeSettings.useStorageAccessFramework(activity)) {
|
||||
return true; // Storage permissions are not needed for SAF
|
||||
}
|
||||
|
||||
if (!checkReadStoragePermissions(activity, requestCode)) {
|
||||
return false;
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
@ -12,6 +12,8 @@ import org.schabi.newpipe.local.dialog.PlaylistCreationDialog;
|
||||
import org.schabi.newpipe.player.MainPlayer;
|
||||
import org.schabi.newpipe.player.helper.PlayerHolder;
|
||||
import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
|
||||
import org.schabi.newpipe.util.external_communication.KoreUtils;
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
@ -66,16 +68,14 @@ public enum StreamDialogEntry {
|
||||
}), // has to be set manually
|
||||
|
||||
append_playlist(R.string.append_playlist, (fragment, item) -> {
|
||||
if (fragment.getFragmentManager() != null) {
|
||||
final PlaylistAppendDialog d = PlaylistAppendDialog
|
||||
.fromStreamInfoItems(Collections.singletonList(item));
|
||||
final PlaylistAppendDialog d = PlaylistAppendDialog
|
||||
.fromStreamInfoItems(Collections.singletonList(item));
|
||||
|
||||
PlaylistAppendDialog.onPlaylistFound(fragment.getContext(),
|
||||
() -> d.show(fragment.getFragmentManager(), "StreamDialogEntry@append_playlist"),
|
||||
() -> PlaylistCreationDialog.newInstance(d)
|
||||
.show(fragment.getFragmentManager(), "StreamDialogEntry@create_playlist")
|
||||
);
|
||||
}
|
||||
PlaylistAppendDialog.onPlaylistFound(fragment.getContext(),
|
||||
() -> d.show(fragment.getParentFragmentManager(), "StreamDialogEntry@append_playlist"),
|
||||
() -> PlaylistCreationDialog.newInstance(d)
|
||||
.show(fragment.getParentFragmentManager(), "StreamDialogEntry@create_playlist")
|
||||
);
|
||||
}),
|
||||
|
||||
play_with_kodi(R.string.play_with_kodi_title, (fragment, item) -> {
|
||||
@ -83,12 +83,13 @@ public enum StreamDialogEntry {
|
||||
try {
|
||||
NavigationHelper.playWithKore(fragment.requireContext(), videoUrl);
|
||||
} catch (final Exception e) {
|
||||
KoreUtil.showInstallKoreDialog(fragment.getActivity());
|
||||
KoreUtils.showInstallKoreDialog(fragment.getActivity());
|
||||
}
|
||||
}),
|
||||
|
||||
share(R.string.share, (fragment, item) ->
|
||||
ShareUtils.shareText(fragment.getContext(), item.getName(), item.getUrl())),
|
||||
ShareUtils.shareText(fragment.getContext(), item.getName(), item.getUrl(),
|
||||
item.getThumbnailUrl())),
|
||||
|
||||
open_in_browser(R.string.open_in_browser, (fragment, item) ->
|
||||
ShareUtils.openUrlInBrowser(fragment.getContext(), item.getUrl()));
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -1,15 +1,18 @@
|
||||
package org.schabi.newpipe.util;
|
||||
|
||||
import org.schabi.newpipe.streams.io.SharpInputStream;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipFile;
|
||||
import java.util.zip.ZipInputStream;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import org.schabi.newpipe.streams.io.StoredFileHelper;
|
||||
|
||||
/**
|
||||
* Created by Christian Schabesberger on 28.01.18.
|
||||
* Copyright 2018 Christian Schabesberger <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.
|
||||
*
|
||||
* @param filePath The path of the zip
|
||||
* @param zipFile The zip file
|
||||
* @param file The path of the file on the disk where the data should be extracted to.
|
||||
* @param name The path of the file inside the zip.
|
||||
* @return will return true if the file was found within the zip file
|
||||
* @throws Exception
|
||||
*/
|
||||
public static boolean extractFileFromZip(final String filePath, final String file,
|
||||
public static boolean extractFileFromZip(final StoredFileHelper zipFile, final String file,
|
||||
final String name) throws Exception {
|
||||
try (ZipInputStream inZip = new ZipInputStream(new BufferedInputStream(
|
||||
new FileInputStream(filePath)))) {
|
||||
new SharpInputStream(zipFile.getStream())))) {
|
||||
final byte[] data = new byte[BUFFER_SIZE];
|
||||
|
||||
boolean found = false;
|
||||
|
||||
ZipEntry ze;
|
||||
|
||||
while ((ze = inZip.getNextEntry()) != null) {
|
||||
if (ze.getName().equals(name)) {
|
||||
found = true;
|
||||
@ -102,8 +104,9 @@ public final class ZipHelper {
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean isValidZipFile(final String filePath) {
|
||||
try (ZipFile ignored = new ZipFile(filePath)) {
|
||||
public static boolean isValidZipFile(final StoredFileHelper file) {
|
||||
try (ZipInputStream ignored = new ZipInputStream(new BufferedInputStream(
|
||||
new SharpInputStream(file.getStream())))) {
|
||||
return true;
|
||||
} catch (final IOException ioe) {
|
||||
return false;
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -1,28 +1,31 @@
|
||||
package org.schabi.newpipe.util;
|
||||
package org.schabi.newpipe.util.external_communication;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.preference.PreferenceManager;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.extractor.ServiceList;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
|
||||
public final class KoreUtil {
|
||||
private KoreUtil() { }
|
||||
public final class KoreUtils {
|
||||
private KoreUtils() { }
|
||||
|
||||
public static boolean isServiceSupportedByKore(final int serviceId) {
|
||||
return (serviceId == ServiceList.YouTube.getServiceId()
|
||||
|| serviceId == ServiceList.SoundCloud.getServiceId());
|
||||
}
|
||||
|
||||
public static boolean shouldShowPlayWithKodi(final Context context, final int serviceId) {
|
||||
public static boolean shouldShowPlayWithKodi(@NonNull final Context context,
|
||||
final int serviceId) {
|
||||
return isServiceSupportedByKore(serviceId)
|
||||
&& PreferenceManager.getDefaultSharedPreferences(context)
|
||||
.getBoolean(context.getString(R.string.show_play_with_kodi_key), false);
|
||||
}
|
||||
|
||||
public static void showInstallKoreDialog(final Context context) {
|
||||
public static void showInstallKoreDialog(@NonNull final Context context) {
|
||||
final AlertDialog.Builder builder = new AlertDialog.Builder(context);
|
||||
builder.setMessage(R.string.kore_not_found)
|
||||
.setPositiveButton(R.string.install, (dialog, which) ->
|
@ -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();
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -26,7 +26,7 @@ import java.util.Objects;
|
||||
|
||||
import javax.net.ssl.SSLException;
|
||||
|
||||
import us.shandian.giga.io.StoredFileHelper;
|
||||
import org.schabi.newpipe.streams.io.StoredFileHelper;
|
||||
import us.shandian.giga.postprocessing.Postprocessing;
|
||||
import us.shandian.giga.service.DownloadManagerService;
|
||||
import us.shandian.giga.util.Utility;
|
||||
|
@ -5,7 +5,7 @@ import androidx.annotation.NonNull;
|
||||
import java.io.Serializable;
|
||||
import java.util.Calendar;
|
||||
|
||||
import us.shandian.giga.io.StoredFileHelper;
|
||||
import org.schabi.newpipe.streams.io.StoredFileHelper;
|
||||
|
||||
public abstract class Mission implements Serializable {
|
||||
private static final long serialVersionUID = 1L;// last bump: 27 march 2019
|
||||
@ -25,6 +25,10 @@ public abstract class Mission implements Serializable {
|
||||
*/
|
||||
public long timestamp;
|
||||
|
||||
public long getTimestamp() {
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* pre-defined content type
|
||||
*/
|
||||
@ -35,10 +39,6 @@ public abstract class Mission implements Serializable {
|
||||
*/
|
||||
public StoredFileHelper storage;
|
||||
|
||||
public long getTimestamp() {
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the downloaded file
|
||||
*
|
||||
@ -57,7 +57,7 @@ public abstract class Mission implements Serializable {
|
||||
@NonNull
|
||||
@Override
|
||||
public String toString() {
|
||||
Calendar calendar = Calendar.getInstance();
|
||||
final Calendar calendar = Calendar.getInstance();
|
||||
calendar.setTimeInMillis(timestamp);
|
||||
return "[" + calendar.getTime().toString() + "] " + (storage.isInvalid() ? storage.getName() : storage.getUri());
|
||||
}
|
||||
|
@ -17,7 +17,7 @@ import java.util.Objects;
|
||||
import us.shandian.giga.get.DownloadMission;
|
||||
import us.shandian.giga.get.FinishedMission;
|
||||
import us.shandian.giga.get.Mission;
|
||||
import us.shandian.giga.io.StoredFileHelper;
|
||||
import org.schabi.newpipe.streams.io.StoredFileHelper;
|
||||
|
||||
/**
|
||||
* SQLite helper to store finished {@link us.shandian.giga.get.FinishedMission}'s
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user