Merge pull request #6576 from TeamNewPipe/release_0.21.6

Release 0.21.6
This commit is contained in:
Tobi 2021-07-12 15:56:21 +02:00 committed by GitHub
commit b795c5f017
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
246 changed files with 5732 additions and 3160 deletions

View File

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

View File

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

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

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

View File

@ -12,18 +12,23 @@
- create clones
- 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).

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,64 +0,0 @@
package org.schabi.newpipe.database;
import androidx.room.TypeConverter;
import org.schabi.newpipe.extractor.stream.StreamType;
import org.schabi.newpipe.local.subscription.FeedGroupIcon;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
public final class Converters {
private Converters() { }
/**
* Convert a long value to a {@link OffsetDateTime}.
*
* @param value the long value
* @return the {@code OffsetDateTime}
*/
@TypeConverter
public static OffsetDateTime offsetDateTimeFromTimestamp(final Long value) {
return value == null ? null : OffsetDateTime.ofInstant(Instant.ofEpochMilli(value),
ZoneOffset.UTC);
}
/**
* Convert a {@link OffsetDateTime} to a long value.
*
* @param offsetDateTime the {@code OffsetDateTime}
* @return the long value
*/
@TypeConverter
public static Long offsetDateTimeToTimestamp(final OffsetDateTime offsetDateTime) {
return offsetDateTime == null ? null : offsetDateTime.withOffsetSameInstant(ZoneOffset.UTC)
.toInstant().toEpochMilli();
}
@TypeConverter
public static StreamType streamTypeOf(final String value) {
return StreamType.valueOf(value);
}
@TypeConverter
public static String stringOf(final StreamType streamType) {
return streamType.name();
}
@TypeConverter
public static Integer integerOf(final FeedGroupIcon feedGroupIcon) {
return feedGroupIcon.getId();
}
@TypeConverter
public static FeedGroupIcon feedGroupIconOf(final Integer id) {
for (final FeedGroupIcon icon : FeedGroupIcon.values()) {
if (icon.getId() == id) {
return icon;
}
}
throw new IllegalArgumentException("There's no feed group icon with the id \"" + id + "\"");
}
}

View File

@ -0,0 +1,52 @@
package org.schabi.newpipe.database
import androidx.room.TypeConverter
import org.schabi.newpipe.extractor.stream.StreamType
import org.schabi.newpipe.local.subscription.FeedGroupIcon
import java.time.Instant
import java.time.OffsetDateTime
import java.time.ZoneOffset
object Converters {
/**
* Convert a long value to a [OffsetDateTime].
*
* @param value the long value
* @return the `OffsetDateTime`
*/
@TypeConverter
fun offsetDateTimeFromTimestamp(value: Long?): OffsetDateTime? {
return value?.let { OffsetDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneOffset.UTC) }
}
/**
* Convert a [OffsetDateTime] to a long value.
*
* @param offsetDateTime the `OffsetDateTime`
* @return the long value
*/
@TypeConverter
fun offsetDateTimeToTimestamp(offsetDateTime: OffsetDateTime?): Long? {
return offsetDateTime?.withOffsetSameInstant(ZoneOffset.UTC)?.toInstant()?.toEpochMilli()
}
@TypeConverter
fun streamTypeOf(value: String): StreamType {
return StreamType.valueOf(value)
}
@TypeConverter
fun stringOf(streamType: StreamType): String {
return streamType.name
}
@TypeConverter
fun integerOf(feedGroupIcon: FeedGroupIcon): Int {
return feedGroupIcon.id
}
@TypeConverter
fun feedGroupIconOf(id: Int): FeedGroupIcon {
return FeedGroupIcon.values().first { it.id == id }
}
}

View File

@ -9,7 +9,8 @@ import androidx.room.Update
import io.reactivex.rxjava3.core.Flowable
import 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(
"""

View File

@ -21,7 +21,7 @@ import static org.schabi.newpipe.database.stream.StreamStatisticsEntry.STREAM_WA
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_ID;
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_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();

View File

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

View File

@ -14,26 +14,26 @@ import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_TABLE;
@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();
}

View File

@ -17,31 +17,31 @@ import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.RE
import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_URL;
@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);
}

View File

@ -25,32 +25,32 @@ import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.PL
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_ID;
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_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();
}

View File

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

View File

@ -0,0 +1,14 @@
package org.schabi.newpipe.database.stream
import androidx.room.ColumnInfo
import androidx.room.Embedded
import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.database.stream.model.StreamStateEntity
data class StreamWithState(
@Embedded
val stream: StreamEntity,
@ColumnInfo(name = StreamStateEntity.STREAM_PROGRESS_MILLIS)
val stateProgressMillis: Long?
)

View File

@ -17,31 +17,31 @@ import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_ST
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE;
@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);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -33,7 +33,7 @@ import org.schabi.newpipe.fragments.OnScrollBelowItemsListener;
import org.schabi.newpipe.info_list.InfoItemDialog;
import org.schabi.newpipe.info_list.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 + "]");

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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: "

View File

@ -12,6 +12,7 @@ import org.schabi.newpipe.NewPipeDatabase
import org.schabi.newpipe.database.feed.model.FeedEntity
import org.schabi.newpipe.database.feed.model.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())
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,153 @@
package org.schabi.newpipe.local.feed.item
import android.content.Context
import android.text.TextUtils
import android.view.View
import androidx.core.content.ContextCompat
import androidx.preference.PreferenceManager
import com.nostra13.universalimageloader.core.ImageLoader
import com.xwray.groupie.viewbinding.BindableItem
import org.schabi.newpipe.MainActivity
import org.schabi.newpipe.R
import org.schabi.newpipe.database.stream.StreamWithState
import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.databinding.ListStreamItemBinding
import org.schabi.newpipe.extractor.stream.StreamType.AUDIO_LIVE_STREAM
import org.schabi.newpipe.extractor.stream.StreamType.AUDIO_STREAM
import org.schabi.newpipe.extractor.stream.StreamType.LIVE_STREAM
import org.schabi.newpipe.extractor.stream.StreamType.VIDEO_STREAM
import org.schabi.newpipe.util.ImageDisplayConstants
import org.schabi.newpipe.util.Localization
import java.util.concurrent.TimeUnit
data class StreamItem(
val streamWithState: StreamWithState,
var itemVersion: ItemVersion = ItemVersion.NORMAL
) : BindableItem<ListStreamItemBinding>() {
companion object {
const val UPDATE_RELATIVE_TIME = 1
}
private val stream: StreamEntity = streamWithState.stream
private val stateProgressTime: Long? = streamWithState.stateProgressMillis
override fun getId(): Long = stream.uid
enum class ItemVersion { NORMAL, MINI, GRID }
override fun getLayout(): Int = when (itemVersion) {
ItemVersion.NORMAL -> R.layout.list_stream_item
ItemVersion.MINI -> R.layout.list_stream_mini_item
ItemVersion.GRID -> R.layout.list_stream_grid_item
}
override fun initializeViewBinding(view: View) = ListStreamItemBinding.bind(view)
override fun bind(viewBinding: ListStreamItemBinding, position: Int, payloads: MutableList<Any>) {
if (payloads.contains(UPDATE_RELATIVE_TIME)) {
if (itemVersion != ItemVersion.MINI) {
viewBinding.itemAdditionalDetails.text =
getStreamInfoDetailLine(viewBinding.itemAdditionalDetails.context)
}
return
}
super.bind(viewBinding, position, payloads)
}
override fun bind(viewBinding: ListStreamItemBinding, position: Int) {
viewBinding.itemVideoTitleView.text = stream.title
viewBinding.itemUploaderView.text = stream.uploader
val isLiveStream = stream.streamType == LIVE_STREAM || stream.streamType == AUDIO_LIVE_STREAM
if (stream.duration > 0) {
viewBinding.itemDurationView.text = Localization.getDurationString(stream.duration)
viewBinding.itemDurationView.setBackgroundColor(
ContextCompat.getColor(
viewBinding.itemDurationView.context,
R.color.duration_background_color
)
)
viewBinding.itemDurationView.visibility = View.VISIBLE
if (stateProgressTime != null) {
viewBinding.itemProgressView.visibility = View.VISIBLE
viewBinding.itemProgressView.max = stream.duration.toInt()
viewBinding.itemProgressView.progress = TimeUnit.MILLISECONDS.toSeconds(stateProgressTime).toInt()
} else {
viewBinding.itemProgressView.visibility = View.GONE
}
} else if (isLiveStream) {
viewBinding.itemDurationView.setText(R.string.duration_live)
viewBinding.itemDurationView.setBackgroundColor(
ContextCompat.getColor(
viewBinding.itemDurationView.context,
R.color.live_duration_background_color
)
)
viewBinding.itemDurationView.visibility = View.VISIBLE
viewBinding.itemProgressView.visibility = View.GONE
} else {
viewBinding.itemDurationView.visibility = View.GONE
viewBinding.itemProgressView.visibility = View.GONE
}
ImageLoader.getInstance().displayImage(
stream.thumbnailUrl, viewBinding.itemThumbnailView,
ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS
)
if (itemVersion != ItemVersion.MINI) {
viewBinding.itemAdditionalDetails.text =
getStreamInfoDetailLine(viewBinding.itemAdditionalDetails.context)
}
}
override fun isLongClickable() = when (stream.streamType) {
AUDIO_STREAM, VIDEO_STREAM, LIVE_STREAM, AUDIO_LIVE_STREAM -> true
else -> false
}
private fun getStreamInfoDetailLine(context: Context): String {
var viewsAndDate = ""
val viewCount = stream.viewCount
if (viewCount != null && viewCount >= 0) {
viewsAndDate = when (stream.streamType) {
AUDIO_LIVE_STREAM -> Localization.listeningCount(context, viewCount)
LIVE_STREAM -> Localization.shortWatchingCount(context, viewCount)
else -> Localization.shortViewCount(context, viewCount)
}
}
val uploadDate = getFormattedRelativeUploadDate(context)
return when {
!TextUtils.isEmpty(uploadDate) -> when {
viewsAndDate.isEmpty() -> uploadDate!!
else -> Localization.concatenateStrings(viewsAndDate, uploadDate)
}
else -> viewsAndDate
}
}
private fun getFormattedRelativeUploadDate(context: Context): String? {
val uploadDate = stream.uploadDate
return if (uploadDate != null) {
var formattedRelativeTime = Localization.relativeTime(uploadDate)
if (MainActivity.DEBUG) {
val key = context.getString(R.string.show_original_time_ago_key)
if (PreferenceManager.getDefaultSharedPreferences(context).getBoolean(key, false)) {
formattedRelativeTime += " (" + stream.textualUploadDate + ")"
}
}
formattedRelativeTime
} else {
stream.textualUploadDate
}
}
override fun getSpanSize(spanCount: Int, position: Int): Int {
return if (itemVersion == ItemVersion.GRID) 1 else spanCount
}
}

View File

@ -48,9 +48,7 @@ import org.schabi.newpipe.MainActivity.DEBUG
import org.schabi.newpipe.R
import org.schabi.newpipe.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) }

View File

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

View File

@ -36,7 +36,7 @@ import org.schabi.newpipe.player.helper.PlayerHolder;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.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);
}

View File

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

View File

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

View File

@ -44,7 +44,7 @@ import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.player.helper.PlayerHolder;
import org.schabi.newpipe.player.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);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,52 @@
package org.schabi.newpipe.streams.io;
import androidx.annotation.NonNull;
import java.io.IOException;
import java.io.InputStream;
/**
* Simply wraps a readable {@link SharpStream} allowing it to be used with built-in Java stuff that
* supports {@link InputStream}.
*/
public class SharpInputStream extends InputStream {
private final SharpStream stream;
public SharpInputStream(final SharpStream stream) throws IOException {
if (!stream.canRead()) {
throw new IOException("SharpStream is not readable");
}
this.stream = stream;
}
@Override
public int read() throws IOException {
return stream.read();
}
@Override
public int read(@NonNull final byte[] b) throws IOException {
return stream.read(b);
}
@Override
public int read(@NonNull final byte[] b, final int off, final int len) throws IOException {
return stream.read(b, off, len);
}
@Override
public long skip(final long n) throws IOException {
return stream.skip(n);
}
@Override
public int available() {
final long res = stream.available();
return res > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) res;
}
@Override
public void close() {
stream.close();
}
}

View File

@ -0,0 +1,46 @@
package org.schabi.newpipe.streams.io;
import androidx.annotation.NonNull;
import java.io.IOException;
import java.io.OutputStream;
/**
* Simply wraps a writable {@link SharpStream} allowing it to be used with built-in Java stuff that
* supports {@link OutputStream}.
*/
public class SharpOutputStream extends OutputStream {
private final SharpStream stream;
public SharpOutputStream(final SharpStream stream) throws IOException {
if (!stream.canWrite()) {
throw new IOException("SharpStream is not writable");
}
this.stream = stream;
}
@Override
public void write(final int b) throws IOException {
stream.write((byte) b);
}
@Override
public void write(@NonNull final byte[] b) throws IOException {
stream.write(b);
}
@Override
public void write(@NonNull final byte[] b, final int off, final int len) throws IOException {
stream.write(b, off, len);
}
@Override
public void flush() throws IOException {
stream.flush();
}
@Override
public void close() {
stream.close();
}
}

View File

@ -1,12 +1,20 @@
package org.schabi.newpipe.streams.io;
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;

View File

@ -1,6 +1,5 @@
package us.shandian.giga.io;
package org.schabi.newpipe.streams.io;
import android.annotation.TargetApi;
import android.content.ContentResolver;
import android.content.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);
}
}
}

View File

@ -0,0 +1,554 @@
package org.schabi.newpipe.streams.io;
import android.annotation.TargetApi;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Build;
import android.os.Environment;
import android.provider.DocumentsContract;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.documentfile.provider.DocumentFile;
import com.nononsenseapps.filepicker.Utils;
import org.schabi.newpipe.settings.NewPipeSettings;
import org.schabi.newpipe.util.FilePickerActivityHelper;
import java.io.File;
import java.io.IOException;
import java.io.Serializable;
import java.net.URI;
import us.shandian.giga.io.FileStream;
import us.shandian.giga.io.FileStreamSAF;
public class StoredFileHelper implements Serializable {
private static final long serialVersionUID = 0L;
public static final String DEFAULT_MIME = "application/octet-stream";
private transient DocumentFile docFile;
private transient DocumentFile docTree;
private transient File ioFile;
private transient Context context;
protected String source;
private String sourceTree;
protected String tag;
private String srcName;
private String srcType;
public StoredFileHelper(final Context context, final Uri uri, final String mime) {
if (FilePickerActivityHelper.isOwnFileUri(context, uri)) {
ioFile = Utils.getFileForUri(uri);
source = Uri.fromFile(ioFile).toString();
} else {
docFile = DocumentFile.fromSingleUri(context, uri);
source = uri.toString();
}
this.context = context;
this.srcType = mime;
}
public StoredFileHelper(@Nullable final Uri parent, final String filename, final String mime,
final String tag) {
this.source = null; // this instance will be "invalid" see invalidate()/isInvalid() methods
this.srcName = filename;
this.srcType = mime == null ? DEFAULT_MIME : mime;
if (parent != null) {
this.sourceTree = parent.toString();
}
this.tag = tag;
}
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
StoredFileHelper(@Nullable final Context context, final DocumentFile tree,
final String filename, final String mime, final boolean safe)
throws IOException {
this.docTree = tree;
this.context = context;
final DocumentFile res;
if (safe) {
// no conflicts (the filename is not in use)
res = this.docTree.createFile(mime, filename);
if (res == null) {
throw new IOException("Cannot create the file");
}
} else {
res = createSAF(context, mime, filename);
}
this.docFile = res;
this.source = docFile.getUri().toString();
this.sourceTree = docTree.getUri().toString();
this.srcName = this.docFile.getName();
this.srcType = this.docFile.getType();
}
StoredFileHelper(final File location, final String filename, final String mime)
throws IOException {
this.ioFile = new File(location, filename);
if (this.ioFile.exists()) {
if (!this.ioFile.isFile() && !this.ioFile.delete()) {
throw new IOException("The filename is already in use by non-file entity "
+ "and cannot overwrite it");
}
} else {
if (!this.ioFile.createNewFile()) {
throw new IOException("Cannot create the file");
}
}
this.source = Uri.fromFile(this.ioFile).toString();
this.sourceTree = Uri.fromFile(location).toString();
this.srcName = ioFile.getName();
this.srcType = mime;
}
@TargetApi(Build.VERSION_CODES.KITKAT)
public StoredFileHelper(final Context context, @Nullable final Uri parent,
@NonNull final Uri path, final String tag) throws IOException {
this.tag = tag;
this.source = path.toString();
if (path.getScheme() == null
|| path.getScheme().equalsIgnoreCase(ContentResolver.SCHEME_FILE)) {
this.ioFile = new File(URI.create(this.source));
} else {
final DocumentFile file = DocumentFile.fromSingleUri(context, path);
if (file == null) {
throw new RuntimeException("SAF not available");
}
this.context = context;
if (file.getName() == null) {
this.source = null;
return;
} else {
this.docFile = file;
takePermissionSAF();
}
}
if (parent != null) {
if (!ContentResolver.SCHEME_FILE.equals(parent.getScheme())) {
this.docTree = DocumentFile.fromTreeUri(context, parent);
}
this.sourceTree = parent.toString();
}
this.srcName = getName();
this.srcType = getType();
}
public static StoredFileHelper deserialize(@NonNull final StoredFileHelper storage,
final Context context) throws IOException {
final Uri treeUri = storage.sourceTree == null ? null : Uri.parse(storage.sourceTree);
if (storage.isInvalid()) {
return new StoredFileHelper(treeUri, storage.srcName, storage.srcType, storage.tag);
}
final StoredFileHelper instance = new StoredFileHelper(context, treeUri,
Uri.parse(storage.source), storage.tag);
// under SAF, if the target document is deleted, conserve the filename and mime
if (instance.srcName == null) {
instance.srcName = storage.srcName;
}
if (instance.srcType == null) {
instance.srcType = storage.srcType;
}
return instance;
}
public SharpStream getStream() throws IOException {
assertValid();
if (docFile == null) {
return new FileStream(ioFile);
} else {
return new FileStreamSAF(context.getContentResolver(), docFile.getUri());
}
}
/**
* Indicates whether it's using the {@code java.io} API.
*
* @return {@code true} for Java I/O API, otherwise, {@code false} for Storage Access Framework
*/
public boolean isDirect() {
assertValid();
return docFile == null;
}
public boolean isInvalid() {
return source == null;
}
public Uri getUri() {
assertValid();
return docFile == null ? Uri.fromFile(ioFile) : docFile.getUri();
}
public Uri getParentUri() {
assertValid();
return sourceTree == null ? null : Uri.parse(sourceTree);
}
public void truncate() throws IOException {
assertValid();
try (SharpStream fs = getStream()) {
fs.setLength(0);
}
}
public boolean delete() {
if (source == null) {
return true;
}
if (docFile == null) {
return ioFile.delete();
}
final boolean res = docFile.delete();
try {
final int flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION;
context.getContentResolver().releasePersistableUriPermission(docFile.getUri(), flags);
} catch (final Exception ex) {
// nothing to do
}
return res;
}
public long length() {
assertValid();
return docFile == null ? ioFile.length() : docFile.length();
}
public boolean canWrite() {
if (source == null) {
return false;
}
return docFile == null ? ioFile.canWrite() : docFile.canWrite();
}
public String getName() {
if (source == null) {
return srcName;
} else if (docFile == null) {
return ioFile.getName();
}
final String name = docFile.getName();
return name == null ? srcName : name;
}
public String getType() {
if (source == null || docFile == null) {
return srcType;
}
final String type = docFile.getType();
return type == null ? srcType : type;
}
public String getTag() {
return tag;
}
public boolean existsAsFile() {
if (source == null) {
return false;
}
// WARNING: DocumentFile.exists() and DocumentFile.isFile() methods are slow
// docFile.isVirtual() means it is non-physical?
return docFile == null
? (ioFile.exists() && ioFile.isFile())
: (docFile.exists() && docFile.isFile());
}
public boolean create() {
assertValid();
final boolean result;
if (docFile == null) {
try {
result = ioFile.createNewFile();
} catch (final IOException e) {
return false;
}
} else if (docTree == null) {
result = false;
} else {
if (!docTree.canRead() || !docTree.canWrite()) {
return false;
}
try {
docFile = createSAF(context, srcType, srcName);
if (docFile.getName() == null) {
return false;
}
result = true;
} catch (final IOException e) {
return false;
}
}
if (result) {
source = (docFile == null ? Uri.fromFile(ioFile) : docFile.getUri()).toString();
srcName = getName();
srcType = getType();
}
return result;
}
public void invalidate() {
if (source == null) {
return;
}
srcName = getName();
srcType = getType();
source = null;
docTree = null;
docFile = null;
ioFile = null;
context = null;
}
public boolean equals(final StoredFileHelper storage) {
if (this == storage) {
return true;
}
// note: do not compare tags, files can have the same parent folder
//if (stringMismatch(this.tag, storage.tag)) return false;
if (stringMismatch(getLowerCase(this.sourceTree), getLowerCase(this.sourceTree))) {
return false;
}
if (this.isInvalid() || storage.isInvalid()) {
if (this.srcName == null || storage.srcName == null || this.srcType == null
|| storage.srcType == null) {
return false;
}
return this.srcName.equalsIgnoreCase(storage.srcName)
&& this.srcType.equalsIgnoreCase(storage.srcType);
}
if (this.isDirect() != storage.isDirect()) {
return false;
}
if (this.isDirect()) {
return this.ioFile.getPath().equalsIgnoreCase(storage.ioFile.getPath());
}
return DocumentsContract.getDocumentId(this.docFile.getUri())
.equalsIgnoreCase(DocumentsContract.getDocumentId(storage.docFile.getUri()));
}
@NonNull
@Override
public String toString() {
if (source == null) {
return "[Invalid state] name=" + srcName + " type=" + srcType + " tag=" + tag;
} else {
return "sourceFile=" + source + " treeSource=" + (sourceTree == null ? "" : sourceTree)
+ " tag=" + tag;
}
}
private void assertValid() {
if (source == null) {
throw new IllegalStateException("In invalid state");
}
}
private void takePermissionSAF() throws IOException {
try {
context.getContentResolver().takePersistableUriPermission(docFile.getUri(),
StoredDirectoryHelper.PERMISSION_FLAGS);
} catch (final Exception e) {
if (docFile.getName() == null) {
throw new IOException(e);
}
}
}
@NonNull
private DocumentFile createSAF(@Nullable final Context ctx, final String mime,
final String filename) throws IOException {
DocumentFile res = StoredDirectoryHelper.findFileSAFHelper(ctx, docTree, filename);
if (res != null && res.exists() && res.isDirectory()) {
if (!res.delete()) {
throw new IOException("Directory with the same name found but cannot delete");
}
res = null;
}
if (res == null) {
res = this.docTree.createFile(srcType == null ? DEFAULT_MIME : mime, filename);
if (res == null) {
throw new IOException("Cannot create the file");
}
}
return res;
}
private String getLowerCase(final String str) {
return str == null ? null : str.toLowerCase();
}
private boolean stringMismatch(final String str1, final String str2) {
if (str1 == null && str2 == null) {
return false;
}
if ((str1 == null) != (str2 == null)) {
return true;
}
return !str1.equals(str2);
}
public static Intent getPicker(@NonNull final Context ctx) {
if (NewPipeSettings.useStorageAccessFramework(ctx)) {
return new Intent(Intent.ACTION_OPEN_DOCUMENT)
.putExtra("android.content.extra.SHOW_ADVANCED", true)
.setType("*/*")
.addCategory(Intent.CATEGORY_OPENABLE)
.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
| StoredDirectoryHelper.PERMISSION_FLAGS);
} else {
return new Intent(ctx, FilePickerActivityHelper.class)
.putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false)
.putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, true)
.putExtra(FilePickerActivityHelper.EXTRA_SINGLE_CLICK, true)
.putExtra(FilePickerActivityHelper.EXTRA_MODE,
FilePickerActivityHelper.MODE_FILE);
}
}
public static Intent getPicker(@NonNull final Context ctx, @Nullable final Uri initialPath) {
return applyInitialPathToPickerIntent(ctx, getPicker(ctx), initialPath, null);
}
public static Intent getNewPicker(@NonNull final Context ctx,
@Nullable final String filename,
@NonNull final String mimeType,
@Nullable final Uri initialPath) {
final Intent i;
if (NewPipeSettings.useStorageAccessFramework(ctx)) {
i = new Intent(Intent.ACTION_CREATE_DOCUMENT)
.putExtra("android.content.extra.SHOW_ADVANCED", true)
.setType(mimeType)
.addCategory(Intent.CATEGORY_OPENABLE)
.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
| StoredDirectoryHelper.PERMISSION_FLAGS);
if (filename != null) {
i.putExtra(Intent.EXTRA_TITLE, filename);
}
} else {
i = new Intent(ctx, FilePickerActivityHelper.class)
.putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false)
.putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, true)
.putExtra(FilePickerActivityHelper.EXTRA_ALLOW_EXISTING_FILE, true)
.putExtra(FilePickerActivityHelper.EXTRA_MODE,
FilePickerActivityHelper.MODE_NEW_FILE);
}
return applyInitialPathToPickerIntent(ctx, i, initialPath, filename);
}
private static Intent applyInitialPathToPickerIntent(@NonNull final Context ctx,
@NonNull final Intent intent,
@Nullable final Uri initialPath,
@Nullable final String filename) {
if (NewPipeSettings.useStorageAccessFramework(ctx)) {
if (initialPath == null) {
return intent; // nothing to do, no initial path provided
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
return intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, initialPath);
} else {
return intent; // can't set initial path on API < 26
}
} else {
if (initialPath == null && filename == null) {
return intent; // nothing to do, no initial path and no file name provided
}
File file;
if (initialPath == null) {
// The only way to set the previewed filename in non-SAF FilePicker is to set a
// starting path ending with that filename. So when the initialPath is null but
// filename isn't just default to the external storage directory.
file = Environment.getExternalStorageDirectory();
} else {
try {
file = Utils.getFileForUri(initialPath);
} catch (final Throwable ignored) {
// getFileForUri() can't decode paths to 'storage', fallback to this
file = new File(initialPath.toString());
}
}
// remove any filename at the end of the path (get the parent directory in that case)
if (!file.exists() || !file.isDirectory()) {
file = file.getParentFile();
if (file == null || !file.exists()) {
// default to the external storage directory in case of an invalid path
file = Environment.getExternalStorageDirectory();
}
// else: file is surely a directory
}
if (filename != null) {
// append a filename so that the non-SAF FilePicker shows it as the preview
file = new File(file, filename);
}
return intent
.putExtra(FilePickerActivityHelper.EXTRA_START_PATH, file.getAbsolutePath());
}
}
}

View File

@ -1,6 +1,5 @@
package org.schabi.newpipe.util;
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;
}
}

View File

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

View File

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

View File

@ -1,22 +0,0 @@
package org.schabi.newpipe.util;
import java.io.File;
public final class FilePathUtils {
private FilePathUtils() { }
/**
* Check that the path is a valid directory path and it exists.
*
* @param path full path of directory,
* @return is path valid or not
*/
public static boolean isValidDirectoryPath(final String path) {
if (path == null || path.isEmpty()) {
return false;
}
final File file = new File(path);
return file.exists() && file.isDirectory();
}
}

View File

@ -1,7 +1,6 @@
package org.schabi.newpipe.util;
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;

View File

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

View File

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

View File

@ -1,229 +0,0 @@
package org.schabi.newpipe.util;
import android.content.ActivityNotFoundException;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.net.Uri;
import android.widget.Toast;
import androidx.core.content.ContextCompat;
import org.schabi.newpipe.R;
public final class ShareUtils {
private ShareUtils() {
}
/**
* Open an Intent to install an app.
* <p>
* This method tries to open the default app market with the package id passed as the
* second param (a system chooser will be opened if there are multiple markets and no default)
* and falls back to Google Play Store web URL if no app to handle the market scheme was found.
* <p>
* It uses {@link ShareUtils#openIntentInApp(Context, Intent)} to open market scheme and
* {@link ShareUtils#openUrlInBrowser(Context, String, boolean)} to open Google Play Store web
* URL with false for the boolean param.
*
* @param context the context to use
* @param packageId the package id of the app to be installed
*/
public static void installApp(final Context context, final String packageId) {
// Try market:// scheme
final boolean marketSchemeResult = openIntentInApp(context, new Intent(Intent.ACTION_VIEW,
Uri.parse("market://details?id=" + packageId))
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK));
if (!marketSchemeResult) {
// Fall back to Google Play Store Web URL (F-Droid can handle it)
openUrlInBrowser(context,
"https://play.google.com/store/apps/details?id=" + packageId, false);
}
}
/**
* Open the url with the system default browser.
* <p>
* If no browser is set as default, fallbacks to
* {@link ShareUtils#openAppChooser(Context, Intent, String)}
*
* @param context the context to use
* @param url the url to browse
* @param httpDefaultBrowserTest the boolean to set if the test for the default browser will be
* for HTTP protocol or for the created intent
* @return true if the URL can be opened or false if it cannot
*/
public static boolean openUrlInBrowser(final Context context, final String url,
final boolean httpDefaultBrowserTest) {
final String defaultPackageName;
final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url))
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
if (httpDefaultBrowserTest) {
defaultPackageName = getDefaultAppPackageName(context, new Intent(Intent.ACTION_VIEW,
Uri.parse("http://")).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK));
} else {
defaultPackageName = getDefaultAppPackageName(context, intent);
}
if (defaultPackageName.equals("android")) {
// No browser set as default (doesn't work on some devices)
openAppChooser(context, intent, context.getString(R.string.open_with));
} else {
if (defaultPackageName.isEmpty()) {
// No app installed to open a web url
Toast.makeText(context, R.string.no_app_to_open_intent, Toast.LENGTH_LONG).show();
return false;
} else {
try {
intent.setPackage(defaultPackageName);
context.startActivity(intent);
} catch (final ActivityNotFoundException e) {
// Not a browser but an app chooser because of OEMs changes
intent.setPackage(null);
openAppChooser(context, intent, context.getString(R.string.open_with));
}
}
}
return true;
}
/**
* Open the url with the system default browser.
* <p>
* If no browser is set as default, fallbacks to
* {@link ShareUtils#openAppChooser(Context, Intent, String)}
* <p>
* This calls {@link ShareUtils#openUrlInBrowser(Context, String, boolean)} with true
* for the boolean parameter
*
* @param context the context to use
* @param url the url to browse
* @return true if the URL can be opened or false if it cannot be
**/
public static boolean openUrlInBrowser(final Context context, final String url) {
return openUrlInBrowser(context, url, true);
}
/**
* Open an intent with the system default app.
* <p>
* The intent can be of every type, excepted a web intent for which
* {@link ShareUtils#openUrlInBrowser(Context, String, boolean)} should be used.
* <p>
* If no app is set as default, fallbacks to
* {@link ShareUtils#openAppChooser(Context, Intent, String)}
*
* @param context the context to use
* @param intent the intent to open
* @return true if the intent can be opened or false if it cannot be
*/
public static boolean openIntentInApp(final Context context, final Intent intent) {
final String defaultPackageName = getDefaultAppPackageName(context, intent);
if (defaultPackageName.equals("android")) {
// No app set as default (doesn't work on some devices)
openAppChooser(context, intent, context.getString(R.string.open_with));
} else {
if (defaultPackageName.isEmpty()) {
// No app installed to open the intent
Toast.makeText(context, R.string.no_app_to_open_intent, Toast.LENGTH_LONG).show();
return false;
} else {
try {
intent.setPackage(defaultPackageName);
context.startActivity(intent);
} catch (final ActivityNotFoundException e) {
// Not an app to open the intent but an app chooser because of OEMs changes
intent.setPackage(null);
openAppChooser(context, intent, context.getString(R.string.open_with));
}
}
}
return true;
}
/**
* Open the system chooser to launch an intent.
* <p>
* This method opens an {@link android.content.Intent#ACTION_CHOOSER} of the intent putted
* as the viewIntent param. A string for the chooser's title must be passed as the last param.
*
* @param context the context to use
* @param intent the intent to open
* @param chooserStringTitle the string of chooser's title
*/
private static void openAppChooser(final Context context, final Intent intent,
final String chooserStringTitle) {
final Intent chooserIntent = new Intent(Intent.ACTION_CHOOSER);
chooserIntent.putExtra(Intent.EXTRA_INTENT, intent);
chooserIntent.putExtra(Intent.EXTRA_TITLE, chooserStringTitle);
chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(chooserIntent);
}
/**
* Get the default app package name.
* <p>
* If no app is set as default, it will return "android" (not on some devices because some
* OEMs changed the app chooser).
* <p>
* If no app is installed on user's device to handle the intent, it will return an empty string.
*
* @param context the context to use
* @param intent the intent to get default app
* @return the package name of the default app, an empty string if there's no app installed to
* handle the intent or the app chooser if there's no default
*/
private static String getDefaultAppPackageName(final Context context, final Intent intent) {
final ResolveInfo resolveInfo = context.getPackageManager().resolveActivity(intent,
PackageManager.MATCH_DEFAULT_ONLY);
if (resolveInfo == null) {
return "";
} else {
return resolveInfo.activityInfo.packageName;
}
}
/**
* Open the android share menu to share the current url.
*
* @param context the context to use
* @param subject the url subject, typically the title
* @param url the url to share
*/
public static void shareText(final Context context, final String subject, final String url) {
final Intent shareIntent = new Intent(Intent.ACTION_SEND);
shareIntent.setType("text/plain");
shareIntent.putExtra(Intent.EXTRA_SUBJECT, subject);
shareIntent.putExtra(Intent.EXTRA_TEXT, url);
openAppChooser(context, shareIntent, context.getString(R.string.share_dialog_title));
}
/**
* Copy the text to clipboard, and indicate to the user whether the operation was completed
* successfully using a Toast.
*
* @param context the context to use
* @param text the text to copy
*/
public static void copyToClipboard(final Context context, final String text) {
final ClipboardManager clipboardManager =
ContextCompat.getSystemService(context, ClipboardManager.class);
if (clipboardManager == null) {
Toast.makeText(context, R.string.permission_denied, Toast.LENGTH_LONG).show();
return;
}
clipboardManager.setPrimaryClip(ClipData.newPlainText(null, text));
Toast.makeText(context, R.string.msg_copied, Toast.LENGTH_SHORT).show();
}
}

View File

@ -12,6 +12,8 @@ import org.schabi.newpipe.local.dialog.PlaylistCreationDialog;
import org.schabi.newpipe.player.MainPlayer;
import org.schabi.newpipe.player.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()));

View File

@ -1,145 +0,0 @@
package org.schabi.newpipe.util;
import android.content.Context;
import android.text.SpannableStringBuilder;
import android.text.method.LinkMovementMethod;
import android.text.style.ClickableSpan;
import android.text.style.URLSpan;
import android.text.util.Linkify;
import android.util.Log;
import android.view.View;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.core.text.HtmlCompat;
import io.noties.markwon.Markwon;
import io.noties.markwon.linkify.LinkifyPlugin;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
public final class TextLinkifier {
public static final String TAG = TextLinkifier.class.getSimpleName();
private TextLinkifier() {
}
/**
* Create web links for contents with an HTML description.
* <p>
* This will call
* {@link TextLinkifier#changeIntentsOfDescriptionLinks(Context, CharSequence, TextView)}
* after having linked the URLs with {@link HtmlCompat#fromHtml(String, int)}.
*
* @param context the context to use
* @param htmlBlock the htmlBlock to be linked
* @param textView the TextView to set the htmlBlock linked
* @param htmlCompatFlag the int flag to be set when {@link HtmlCompat#fromHtml(String, int)}
* will be called
* @return a disposable to be stored somewhere and disposed when activity/fragment is destroyed
*/
public static Disposable createLinksFromHtmlBlock(final Context context,
final String htmlBlock,
final TextView textView,
final int htmlCompatFlag) {
return changeIntentsOfDescriptionLinks(context,
HtmlCompat.fromHtml(htmlBlock, htmlCompatFlag), textView);
}
/**
* Create web links for contents with a plain text description.
* <p>
* This will call
* {@link TextLinkifier#changeIntentsOfDescriptionLinks(Context, CharSequence, TextView)}
* after having linked the URLs with {@link TextView#setAutoLinkMask(int)} and
* {@link TextView#setText(CharSequence, TextView.BufferType)}.
*
* @param context the context to use
* @param plainTextBlock the block of plain text to be linked
* @param textView the TextView to set the plain text block linked
* @return a disposable to be stored somewhere and disposed when activity/fragment is destroyed
*/
public static Disposable createLinksFromPlainText(final Context context,
final String plainTextBlock,
final TextView textView) {
textView.setAutoLinkMask(Linkify.WEB_URLS);
textView.setText(plainTextBlock, TextView.BufferType.SPANNABLE);
return changeIntentsOfDescriptionLinks(context, textView.getText(), textView);
}
/**
* Create web links for contents with a markdown description.
* <p>
* This will call
* {@link TextLinkifier#changeIntentsOfDescriptionLinks(Context, CharSequence, TextView)}
* after creating an {@link Markwon} object and using
* {@link Markwon#setMarkdown(TextView, String)}.
*
* @param context the context to use
* @param markdownBlock the block of markdown text to be linked
* @param textView the TextView to set the plain text block linked
* @return a disposable to be stored somewhere and disposed when activity/fragment is destroyed
*/
public static Disposable createLinksFromMarkdownText(final Context context,
final String markdownBlock,
final TextView textView) {
final Markwon markwon = Markwon.builder(context).usePlugin(LinkifyPlugin.create()).build();
markwon.setMarkdown(textView, markdownBlock);
return changeIntentsOfDescriptionLinks(context, textView.getText(), textView);
}
/**
* Change links generated by libraries in the description of a content to a custom link action.
* <p>
* Instead of using an {@link android.content.Intent#ACTION_VIEW} intent in the description of a
* content, this method will parse the {@link CharSequence} and replace all current web links
* with {@link ShareUtils#openUrlInBrowser(Context, String, boolean)}.
* <p>
* This method is required in order to intercept links and e.g. show a confirmation dialog
* before opening a web link.
*
* @param context the context to use
* @param chars the CharSequence to be parsed
* @param textView the TextView in which the converted CharSequence will be applied
* @return a disposable to be stored somewhere and disposed when activity/fragment is destroyed
*/
private static Disposable changeIntentsOfDescriptionLinks(final Context context,
final CharSequence chars,
final TextView textView) {
return Single.fromCallable(() -> {
final SpannableStringBuilder textBlockLinked = new SpannableStringBuilder(chars);
final URLSpan[] urls = textBlockLinked.getSpans(0, chars.length(), URLSpan.class);
for (final URLSpan span : urls) {
final ClickableSpan clickableSpan = new ClickableSpan() {
public void onClick(@NonNull final View view) {
ShareUtils.openUrlInBrowser(context, span.getURL(), false);
}
};
textBlockLinked.setSpan(clickableSpan, textBlockLinked.getSpanStart(span),
textBlockLinked.getSpanEnd(span), textBlockLinked.getSpanFlags(span));
textBlockLinked.removeSpan(span);
}
return textBlockLinked;
}).subscribeOn(Schedulers.computation())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
textBlockLinked -> setTextViewCharSequence(textView, textBlockLinked),
throwable -> {
Log.e(TAG, "Unable to linkify text", throwable);
// this should never happen, but if it does, just fallback to it
setTextViewCharSequence(textView, chars);
});
}
private static void setTextViewCharSequence(final TextView textView,
final CharSequence charSequence) {
textView.setText(charSequence);
textView.setMovementMethod(LinkMovementMethod.getInstance());
textView.setVisibility(View.VISIBLE);
}
}

View File

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

View File

@ -0,0 +1,154 @@
package org.schabi.newpipe.util.external_communication;
import android.content.Context;
import androidx.annotation.NonNull;
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
import org.schabi.newpipe.extractor.exceptions.ParsingException;
import org.schabi.newpipe.extractor.linkhandler.LinkHandlerFactory;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
import org.schabi.newpipe.util.ExtractorHelper;
import org.schabi.newpipe.util.NavigationHelper;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
public final class InternalUrlsHandler {
private static final Pattern AMPERSAND_TIMESTAMP_PATTERN = Pattern.compile("(.*)&t=(\\d+)");
private static final Pattern HASHTAG_TIMESTAMP_PATTERN =
Pattern.compile("(.*)#timestamp=(\\d+)");
private InternalUrlsHandler() {
}
/**
* Handle a YouTube timestamp comment URL in NewPipe.
* <p>
* This method will check if the provided url is a YouTube comment description URL ({@code
* https://www.youtube.com/watch?v=}video_id{@code #timestamp=}time_in_seconds). If yes, the
* popup player will be opened when the user will click on the timestamp in the comment,
* at the time and for the video indicated in the timestamp.
*
* @param disposables a field of the Activity/Fragment class that calls this method
* @param context the context to use
* @param url the URL to check if it can be handled
* @return true if the URL can be handled by NewPipe, false if it cannot
*/
public static boolean handleUrlCommentsTimestamp(@NonNull final CompositeDisposable
disposables,
final Context context,
@NonNull final String url) {
return handleUrl(context, url, HASHTAG_TIMESTAMP_PATTERN, disposables);
}
/**
* Handle a YouTube timestamp description URL in NewPipe.
* <p>
* This method will check if the provided url is a YouTube timestamp description URL ({@code
* https://www.youtube.com/watch?v=}video_id{@code &t=}time_in_seconds). If yes, the popup
* player will be opened when the user will click on the timestamp in the video description,
* at the time and for the video indicated in the timestamp.
*
* @param disposables a field of the Activity/Fragment class that calls this method
* @param context the context to use
* @param url the URL to check if it can be handled
* @return true if the URL can be handled by NewPipe, false if it cannot
*/
public static boolean handleUrlDescriptionTimestamp(@NonNull final CompositeDisposable
disposables,
final Context context,
@NonNull final String url) {
return handleUrl(context, url, AMPERSAND_TIMESTAMP_PATTERN, disposables);
}
/**
* Handle an URL in NewPipe.
* <p>
* This method will check if the provided url can be handled in NewPipe or not. If this is a
* service URL with a timestamp, the popup player will be opened and true will be returned;
* else, false will be returned.
*
* @param context the context to use
* @param url the URL to check if it can be handled
* @param pattern the pattern to use
* @param disposables a field of the Activity/Fragment class that calls this method
* @return true if the URL can be handled by NewPipe, false if it cannot
*/
private static boolean handleUrl(final Context context,
@NonNull final String url,
@NonNull final Pattern pattern,
@NonNull final CompositeDisposable disposables) {
final Matcher matcher = pattern.matcher(url);
if (!matcher.matches()) {
return false;
}
final String matchedUrl = matcher.group(1);
final int seconds = Integer.parseInt(matcher.group(2));
final StreamingService service;
final StreamingService.LinkType linkType;
try {
service = NewPipe.getServiceByUrl(matchedUrl);
linkType = service.getLinkTypeByUrl(matchedUrl);
if (linkType == StreamingService.LinkType.NONE) {
return false;
}
} catch (final ExtractionException e) {
return false;
}
if (linkType == StreamingService.LinkType.STREAM && seconds != -1) {
return playOnPopup(context, matchedUrl, service, seconds, disposables);
} else {
NavigationHelper.openRouterActivity(context, matchedUrl);
return true;
}
}
/**
* Play a content in the floating player.
*
* @param context the context to be used
* @param url the URL of the content
* @param service the service of the content
* @param seconds the position in seconds at which the floating player will start
* @param disposables disposables created by the method are added here and their lifecycle
* should be handled by the calling class
* @return true if the playback of the content has successfully started or false if not
*/
public static boolean playOnPopup(final Context context,
final String url,
@NonNull final StreamingService service,
final int seconds,
@NonNull final CompositeDisposable disposables) {
final LinkHandlerFactory factory = service.getStreamLHFactory();
final String cleanUrl;
try {
cleanUrl = factory.getUrl(factory.getId(url));
} catch (final ParsingException e) {
return false;
}
final Single<StreamInfo> single
= ExtractorHelper.getStreamInfo(service.getServiceId(), cleanUrl, false);
disposables.add(single.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(info -> {
final PlayQueue playQueue
= new SinglePlayQueue(info, seconds * 1000);
NavigationHelper.playOnPopupPlayer(context, playQueue, false);
}));
return true;
}
}

View File

@ -1,28 +1,31 @@
package org.schabi.newpipe.util;
package org.schabi.newpipe.util.external_communication;
import android.content.Context;
import 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) ->

View File

@ -0,0 +1,302 @@
package org.schabi.newpipe.util.external_communication;
import android.content.ActivityNotFoundException;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.net.Uri;
import android.os.Build;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.core.content.ContextCompat;
import org.schabi.newpipe.R;
public final class ShareUtils {
private ShareUtils() {
}
/**
* Open an Intent to install an app.
* <p>
* This method tries to open the default app market with the package id passed as the
* second param (a system chooser will be opened if there are multiple markets and no default)
* and falls back to Google Play Store web URL if no app to handle the market scheme was found.
* <p>
* It uses {@link #openIntentInApp(Context, Intent, boolean)} to open market scheme
* and {@link #openUrlInBrowser(Context, String, boolean)} to open Google Play Store
* web URL with false for the boolean param.
*
* @param context the context to use
* @param packageId the package id of the app to be installed
*/
public static void installApp(@NonNull final Context context, final String packageId) {
// Try market scheme
final boolean marketSchemeResult = openIntentInApp(context, new Intent(Intent.ACTION_VIEW,
Uri.parse("market://details?id=" + packageId))
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK), false);
if (!marketSchemeResult) {
// Fall back to Google Play Store Web URL (F-Droid can handle it)
openUrlInBrowser(context,
"https://play.google.com/store/apps/details?id=" + packageId, false);
}
}
/**
* Open the url with the system default browser.
* <p>
* If no browser is set as default, fallbacks to
* {@link #openAppChooser(Context, Intent, boolean)}
*
* @param context the context to use
* @param url the url to browse
* @param httpDefaultBrowserTest the boolean to set if the test for the default browser will be
* for HTTP protocol or for the created intent
* @return true if the URL can be opened or false if it cannot
*/
public static boolean openUrlInBrowser(@NonNull final Context context,
final String url,
final boolean httpDefaultBrowserTest) {
final String defaultPackageName;
final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url))
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
if (httpDefaultBrowserTest) {
defaultPackageName = getDefaultAppPackageName(context, new Intent(Intent.ACTION_VIEW,
Uri.parse("http://")).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK));
} else {
defaultPackageName = getDefaultAppPackageName(context, intent);
}
if (defaultPackageName.equals("android")) {
// No browser set as default (doesn't work on some devices)
openAppChooser(context, intent, true);
} else {
if (defaultPackageName.isEmpty()) {
// No app installed to open a web url
Toast.makeText(context, R.string.no_app_to_open_intent, Toast.LENGTH_LONG).show();
return false;
} else {
try {
intent.setPackage(defaultPackageName);
context.startActivity(intent);
} catch (final ActivityNotFoundException e) {
// Not a browser but an app chooser because of OEMs changes
intent.setPackage(null);
openAppChooser(context, intent, true);
}
}
}
return true;
}
/**
* Open the url with the system default browser.
* <p>
* If no browser is set as default, fallbacks to
* {@link #openAppChooser(Context, Intent, boolean)}
* <p>
* This calls {@link #openUrlInBrowser(Context, String, boolean)} with true
* for the boolean parameter
*
* @param context the context to use
* @param url the url to browse
* @return true if the URL can be opened or false if it cannot be
**/
public static boolean openUrlInBrowser(@NonNull final Context context, final String url) {
return openUrlInBrowser(context, url, true);
}
/**
* Open an intent with the system default app.
* <p>
* The intent can be of every type, excepted a web intent for which
* {@link #openUrlInBrowser(Context, String, boolean)} should be used.
* <p>
* If no app can open the intent, a toast with the message {@code No app on your device can
* open this} is shown.
*
* @param context the context to use
* @param intent the intent to open
* @param showToast a boolean to set if a toast is displayed to user when no app is installed
* to open the intent (true) or not (false)
* @return true if the intent can be opened or false if it cannot be
*/
public static boolean openIntentInApp(@NonNull final Context context,
@NonNull final Intent intent,
final boolean showToast) {
final String defaultPackageName = getDefaultAppPackageName(context, intent);
if (defaultPackageName.isEmpty()) {
// No app installed to open the intent
if (showToast) {
Toast.makeText(context, R.string.no_app_to_open_intent, Toast.LENGTH_LONG)
.show();
}
return false;
} else {
context.startActivity(intent);
}
return true;
}
/**
* Open the system chooser to launch an intent.
* <p>
* This method opens an {@link android.content.Intent#ACTION_CHOOSER} of the intent putted
* as the intent param. If the setTitleChooser boolean is true, the string "Open with" will be
* set as the title of the system chooser.
* For Android P and higher, title for {@link android.content.Intent#ACTION_SEND} system
* choosers must be set on this intent, not on the
* {@link android.content.Intent#ACTION_CHOOSER} intent.
*
* @param context the context to use
* @param intent the intent to open
* @param setTitleChooser set the title "Open with" to the chooser if true, else not
*/
private static void openAppChooser(@NonNull final Context context,
@NonNull final Intent intent,
final boolean setTitleChooser) {
final Intent chooserIntent = new Intent(Intent.ACTION_CHOOSER);
chooserIntent.putExtra(Intent.EXTRA_INTENT, intent);
chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
if (setTitleChooser) {
chooserIntent.putExtra(Intent.EXTRA_TITLE, context.getString(R.string.open_with));
}
// Migrate any clip data and flags from the original intent.
final int permFlags;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
permFlags = intent.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION
| Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
| Intent.FLAG_GRANT_PREFIX_URI_PERMISSION);
} else {
permFlags = intent.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION
| Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION);
}
if (permFlags != 0) {
ClipData targetClipData = intent.getClipData();
if (targetClipData == null && intent.getData() != null) {
final ClipData.Item item = new ClipData.Item(intent.getData());
final String[] mimeTypes;
if (intent.getType() != null) {
mimeTypes = new String[] {intent.getType()};
} else {
mimeTypes = new String[] {};
}
targetClipData = new ClipData(null, mimeTypes, item);
}
if (targetClipData != null) {
chooserIntent.setClipData(targetClipData);
chooserIntent.addFlags(permFlags);
}
}
context.startActivity(chooserIntent);
}
/**
* Get the default app package name.
* <p>
* If no app is set as default, it will return "android" (not on some devices because some
* OEMs changed the app chooser).
* <p>
* If no app is installed on user's device to handle the intent, it will return an empty string.
*
* @param context the context to use
* @param intent the intent to get default app
* @return the package name of the default app, an empty string if there's no app installed to
* handle the intent or the app chooser if there's no default
*/
private static String getDefaultAppPackageName(@NonNull final Context context,
@NonNull final Intent intent) {
final ResolveInfo resolveInfo = context.getPackageManager().resolveActivity(intent,
PackageManager.MATCH_DEFAULT_ONLY);
if (resolveInfo == null) {
return "";
} else {
return resolveInfo.activityInfo.packageName;
}
}
/**
* Open the android share sheet to share a content.
*
* For Android 10+ users, a content preview is shown, which includes the title of the shared
* content.
* Support sharing the image of the content needs to done, if possible.
*
* @param context the context to use
* @param title the title of the content
* @param content the content to share
* @param imagePreviewUrl the image of the subject
*/
public static void shareText(@NonNull final Context context,
@NonNull final String title,
final String content,
final String imagePreviewUrl) {
final Intent shareIntent = new Intent(Intent.ACTION_SEND);
shareIntent.setType("text/plain");
shareIntent.putExtra(Intent.EXTRA_TEXT, content);
if (!title.isEmpty()) {
shareIntent.putExtra(Intent.EXTRA_TITLE, title);
}
/* TODO: add the image of the content to Android share sheet with setClipData after
generating a content URI of this image, then use ClipData.newUri(the content resolver,
null, the content URI) and set the ClipData to the share intent with
shareIntent.setClipData(generated ClipData).
if (!imagePreviewUrl.isEmpty()) {
//shareIntent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
}*/
openAppChooser(context, shareIntent, false);
}
/**
* Open the android share sheet to share a content.
*
* For Android 10+ users, a content preview is shown, which includes the title of the shared
* content.
* <p>
* This calls {@link #shareText(Context, String, String, String)} with an empty string for the
* imagePreviewUrl parameter.
*
* @param context the context to use
* @param title the title of the content
* @param content the content to share
*/
public static void shareText(@NonNull final Context context,
@NonNull final String title,
final String content) {
shareText(context, title, content, "");
}
/**
* Copy the text to clipboard, and indicate to the user whether the operation was completed
* successfully using a Toast.
*
* @param context the context to use
* @param text the text to copy
*/
public static void copyToClipboard(@NonNull final Context context, final String text) {
final ClipboardManager clipboardManager =
ContextCompat.getSystemService(context, ClipboardManager.class);
if (clipboardManager == null) {
Toast.makeText(context, R.string.permission_denied, Toast.LENGTH_LONG).show();
return;
}
clipboardManager.setPrimaryClip(ClipData.newPlainText(null, text));
Toast.makeText(context, R.string.msg_copied, Toast.LENGTH_SHORT).show();
}
}

View File

@ -0,0 +1,287 @@
package org.schabi.newpipe.util.external_communication;
import android.content.Context;
import android.text.SpannableStringBuilder;
import android.text.method.LinkMovementMethod;
import android.text.style.ClickableSpan;
import android.text.style.URLSpan;
import android.text.util.Linkify;
import android.util.Log;
import android.view.View;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.text.HtmlCompat;
import org.schabi.newpipe.extractor.Info;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.util.NavigationHelper;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import io.noties.markwon.Markwon;
import io.noties.markwon.linkify.LinkifyPlugin;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
import static org.schabi.newpipe.util.external_communication.InternalUrlsHandler.playOnPopup;
public final class TextLinkifier {
public static final String TAG = TextLinkifier.class.getSimpleName();
private static final Pattern HASHTAGS_PATTERN = Pattern.compile("(#[A-Za-z0-9_]+)");
private static final Pattern TIMESTAMPS_PATTERN = Pattern.compile(
"(?:^|(?!:)\\W)(?:([0-5]?[0-9]):)?([0-5]?[0-9]):([0-5][0-9])(?=$|(?!:)\\W)");
private TextLinkifier() {
}
/**
* Create web links for contents with an HTML description.
* <p>
* This will call {@link TextLinkifier#changeIntentsOfDescriptionLinks(TextView, CharSequence,
* Info, CompositeDisposable)} after having linked the URLs with
* {@link HtmlCompat#fromHtml(String, int)}.
*
* @param textView the TextView to set the htmlBlock linked
* @param htmlBlock the htmlBlock to be linked
* @param htmlCompatFlag the int flag to be set when {@link HtmlCompat#fromHtml(String, int)}
* will be called
* @param relatedInfo if given, handle timestamps to open the stream in the popup player at
* the specific time, and hashtags to search for the term in the correct
* service
* @param disposables disposables created by the method are added here and their lifecycle
* should be handled by the calling class
*/
public static void createLinksFromHtmlBlock(@NonNull final TextView textView,
final String htmlBlock,
final int htmlCompatFlag,
@Nullable final Info relatedInfo,
final CompositeDisposable disposables) {
changeIntentsOfDescriptionLinks(
textView, HtmlCompat.fromHtml(htmlBlock, htmlCompatFlag), relatedInfo, disposables);
}
/**
* Create web links for contents with a plain text description.
* <p>
* This will call {@link TextLinkifier#changeIntentsOfDescriptionLinks(TextView, CharSequence,
* Info, CompositeDisposable)} after having linked the URLs with
* {@link TextView#setAutoLinkMask(int)} and
* {@link TextView#setText(CharSequence, TextView.BufferType)}.
*
* @param textView the TextView to set the plain text block linked
* @param plainTextBlock the block of plain text to be linked
* @param relatedInfo if given, handle timestamps to open the stream in the popup player at
* the specific time, and hashtags to search for the term in the correct
* service
* @param disposables disposables created by the method are added here and their lifecycle
* should be handled by the calling class
*/
public static void createLinksFromPlainText(@NonNull final TextView textView,
final String plainTextBlock,
@Nullable final Info relatedInfo,
final CompositeDisposable disposables) {
textView.setAutoLinkMask(Linkify.WEB_URLS);
textView.setText(plainTextBlock, TextView.BufferType.SPANNABLE);
changeIntentsOfDescriptionLinks(textView, textView.getText(), relatedInfo, disposables);
}
/**
* Create web links for contents with a markdown description.
* <p>
* This will call {@link TextLinkifier#changeIntentsOfDescriptionLinks(TextView, CharSequence,
* Info, CompositeDisposable)} after creating an {@link Markwon} object and using
* {@link Markwon#setMarkdown(TextView, String)}.
*
* @param textView the TextView to set the plain text block linked
* @param markdownBlock the block of markdown text to be linked
* @param relatedInfo if given, handle timestamps to open the stream in the popup player at
* the specific time, and hashtags to search for the term in the correct
* @param disposables disposables created by the method are added here and their lifecycle
* should be handled by the calling class
*/
public static void createLinksFromMarkdownText(@NonNull final TextView textView,
final String markdownBlock,
@Nullable final Info relatedInfo,
final CompositeDisposable disposables) {
final Markwon markwon = Markwon.builder(textView.getContext())
.usePlugin(LinkifyPlugin.create()).build();
changeIntentsOfDescriptionLinks(textView, markwon.toMarkdown(markdownBlock), relatedInfo,
disposables);
}
/**
* Add click listeners which opens a search on hashtags in a plain text.
* <p>
* This method finds all timestamps in the {@link SpannableStringBuilder} of the description
* using a regular expression, adds for each a {@link ClickableSpan} which opens
* {@link NavigationHelper#openSearch(Context, int, String)} and makes a search on the hashtag,
* in the service of the content.
*
* @param context the context to use
* @param spannableDescription the SpannableStringBuilder with the text of the
* content description
* @param relatedInfo used to search for the term in the correct service
*/
private static void addClickListenersOnHashtags(final Context context,
@NonNull final SpannableStringBuilder
spannableDescription,
final Info relatedInfo) {
final String descriptionText = spannableDescription.toString();
final Matcher hashtagsMatches = HASHTAGS_PATTERN.matcher(descriptionText);
while (hashtagsMatches.find()) {
final int hashtagStart = hashtagsMatches.start(1);
final int hashtagEnd = hashtagsMatches.end(1);
final String parsedHashtag = descriptionText.substring(hashtagStart, hashtagEnd);
// don't add a ClickableSpan if there is already one, which should be a part of an URL,
// already parsed before
if (spannableDescription.getSpans(hashtagStart, hashtagEnd,
ClickableSpan.class).length == 0) {
spannableDescription.setSpan(new ClickableSpan() {
@Override
public void onClick(@NonNull final View view) {
NavigationHelper.openSearch(context, relatedInfo.getServiceId(),
parsedHashtag);
}
}, hashtagStart, hashtagEnd, 0);
}
}
}
/**
* Add click listeners which opens the popup player on timestamps in a plain text.
* <p>
* This method finds all timestamps in the {@link SpannableStringBuilder} of the description
* using a regular expression, adds for each a {@link ClickableSpan} which opens the popup
* player at the time indicated in the timestamps.
*
* @param context the context to use
* @param spannableDescription the SpannableStringBuilder with the text of the
* content description
* @param relatedInfo what to open in the popup player when timestamps are clicked
* @param disposables disposables created by the method are added here and their
* lifecycle should be handled by the calling class
*/
private static void addClickListenersOnTimestamps(final Context context,
@NonNull final SpannableStringBuilder
spannableDescription,
final Info relatedInfo,
final CompositeDisposable disposables) {
final String descriptionText = spannableDescription.toString();
final Matcher timestampsMatches = TIMESTAMPS_PATTERN.matcher(descriptionText);
while (timestampsMatches.find()) {
final int timestampStart = timestampsMatches.start(2);
final int timestampEnd = timestampsMatches.end(3);
final String parsedTimestamp = descriptionText.substring(timestampStart, timestampEnd);
final String[] timestampParts = parsedTimestamp.split(":");
final int seconds;
if (timestampParts.length == 3) { // timestamp format: XX:XX:XX
seconds = Integer.parseInt(timestampParts[0]) * 3600 // hours
+ Integer.parseInt(timestampParts[1]) * 60 // minutes
+ Integer.parseInt(timestampParts[2]); // seconds
} else if (timestampParts.length == 2) { // timestamp format: XX:XX
seconds = Integer.parseInt(timestampParts[0]) * 60 // minutes
+ Integer.parseInt(timestampParts[1]); // seconds
} else {
continue;
}
spannableDescription.setSpan(new ClickableSpan() {
@Override
public void onClick(@NonNull final View view) {
playOnPopup(context, relatedInfo.getUrl(), relatedInfo.getService(), seconds,
disposables);
}
}, timestampStart, timestampEnd, 0);
}
}
/**
* Change links generated by libraries in the description of a content to a custom link action
* and add click listeners on timestamps in this description.
* <p>
* Instead of using an {@link android.content.Intent#ACTION_VIEW} intent in the description of
* a content, this method will parse the {@link CharSequence} and replace all current web links
* with {@link ShareUtils#openUrlInBrowser(Context, String, boolean)}.
* This method will also add click listeners on timestamps in this description, which will play
* the content in the popup player at the time indicated in the timestamp, by using
* {@link TextLinkifier#addClickListenersOnTimestamps(Context, SpannableStringBuilder, Info,
* CompositeDisposable)} method and click listeners on hashtags, by using
* {@link TextLinkifier#addClickListenersOnHashtags(Context, SpannableStringBuilder, Info)},
* which will open a search on the current service with the hashtag.
* <p>
* This method is required in order to intercept links and e.g. show a confirmation dialog
* before opening a web link.
*
* @param textView the TextView in which the converted CharSequence will be applied
* @param chars the CharSequence to be parsed
* @param relatedInfo if given, handle timestamps to open the stream in the popup player at
* the specific time, and hashtags to search for the term in the correct
* service
* @param disposables disposables created by the method are added here and their lifecycle
* should be handled by the calling class
*/
private static void changeIntentsOfDescriptionLinks(final TextView textView,
final CharSequence chars,
@Nullable final Info relatedInfo,
final CompositeDisposable disposables) {
disposables.add(Single.fromCallable(() -> {
final Context context = textView.getContext();
// add custom click actions on web links
final SpannableStringBuilder textBlockLinked = new SpannableStringBuilder(chars);
final URLSpan[] urls = textBlockLinked.getSpans(0, chars.length(), URLSpan.class);
for (final URLSpan span : urls) {
final String url = span.getURL();
final ClickableSpan clickableSpan = new ClickableSpan() {
public void onClick(@NonNull final View view) {
if (!InternalUrlsHandler.handleUrlDescriptionTimestamp(
new CompositeDisposable(), context, url)) {
ShareUtils.openUrlInBrowser(context, url, false);
}
}
};
textBlockLinked.setSpan(clickableSpan, textBlockLinked.getSpanStart(span),
textBlockLinked.getSpanEnd(span), textBlockLinked.getSpanFlags(span));
textBlockLinked.removeSpan(span);
}
// add click actions on plain text timestamps only for description of contents,
// unneeded for meta-info or other TextViews
if (relatedInfo != null) {
if (relatedInfo instanceof StreamInfo) {
addClickListenersOnTimestamps(context, textBlockLinked, relatedInfo,
disposables);
}
addClickListenersOnHashtags(context, textBlockLinked, relatedInfo);
}
return textBlockLinked;
}).subscribeOn(Schedulers.computation())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
textBlockLinked -> setTextViewCharSequence(textView, textBlockLinked),
throwable -> {
Log.e(TAG, "Unable to linkify text", throwable);
// this should never happen, but if it does, just fallback to it
setTextViewCharSequence(textView, chars);
}));
}
private static void setTextViewCharSequence(@NonNull final TextView textView,
final CharSequence charSequence) {
textView.setText(charSequence);
textView.setMovementMethod(LinkMovementMethod.getInstance());
textView.setVisibility(View.VISIBLE);
}
}

View File

@ -26,7 +26,7 @@ import java.util.Objects;
import javax.net.ssl.SSLException;
import 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;

View File

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

View File

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