diff --git a/.github/README.md b/.github/README.md new file mode 100644 index 0000000000..c5c2420f2a --- /dev/null +++ b/.github/README.md @@ -0,0 +1,66 @@ +# The Last Pipe Bender +> NewPipe + PipePipe + BraveNewPipe + Tubular + +# Forks +- [x] NewPipe +- [x] Tubular +- [ ] BraveNewPipe +- [ ] PipePipe + + diff --git a/.github/scripts/brave-new-pipe-releast-actions.sh b/.github/scripts/brave-new-pipe-releast-actions.sh new file mode 100755 index 0000000000..1611bc3cf6 --- /dev/null +++ b/.github/scripts/brave-new-pipe-releast-actions.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# this script updates the json file with new version that BraveNewPipe is fetching regulary + +set -e + +if [[ $# -ne 2 ]]; then + echo "This needs a release tag and a apk file:" + echo "e.g. $0 v0.22.0-1.0.5 /path/to/BraveNewPipe_v0.22.0-1.0.5.apk" + exit 1 +fi + +if [[ -z "$GITHUB_SUPER_TOKEN" ]]; then + echo "This script needs a GitHub personal access token." + exit 1 +fi + +TAG=$1 +APK_FILE=$2 + +BNP_R_MGR_REPO="bnp-r-mgr" + +GITHUB_USER="bravenewpipe" +RELEASE_REPO="NewPipe" +RELEASE_BODY="Apk available at ${GITHUB_USER}/${RELEASE_REPO}@${TAG}](https://github.com/${GITHUB_USER}/${RELEASE_REPO}/releases/tag/${TAG})." + +PRERELEASE="false" +if [[ "$TAG" == "latest" ]]; then + PRERELEASE="true" +fi + +if [[ "$GITHUB_REPOSITORY" != "${GITHUB_USER}/${RELEASE_REPO}" ]]; then + echo "This mirror script is only meant to be run from ${GITHUB_USER}/${RELEASE_REPO}, not ${GITHUB_REPOSITORY}. Nothing to do here." + exit 0 +fi + +create_tagged_release() { + local L_REPO=$1 + local L_BRANCH=$2 + local L_COMMIT_MSG=$3 + pushd /tmp/${L_REPO}/ + + # Set the local git identity + git config user.email "${GITHUB_USER}@users.noreply.github.com" + git config user.name "$GITHUB_USER" + + # Obtain the release ID for the previous release of $TAG (if present) + local previous_release_id=$(curl --user ${GITHUB_USER}:${GITHUB_SUPER_TOKEN} --request GET --silent https://api.github.com/repos/${GITHUB_USER}/${L_REPO}/releases/tags/${TAG} | jq '.id') + + # Delete the previous release (if present) + if [[ -n "$previous_release_id" ]]; then + echo "Deleting previous release: ${previous_release_id}" + curl \ + --user ${GITHUB_USER}:${GITHUB_SUPER_TOKEN} \ + --request DELETE \ + --silent \ + https://api.github.com/repos/${GITHUB_USER}/${L_REPO}/releases/${previous_release_id} + fi + + # Delete previous identical tags, if present + git tag -d $TAG || true + git push origin :$TAG || true + + # Add all the changed files and push the changes upstream + git add -f . + git commit -m "${L_COMMIT_MSG}" || true + git push -f origin ${L_BRANCH}:${L_BRANCH} + git tag $TAG + git push origin $TAG + +# evermind -- we don't want any release entries there # Generate a skeleton release on GitHub +# evermind -- we don't want any release entries there curl \ +# evermind -- we don't want any release entries there --user ${GITHUB_USER}:${GITHUB_SUPER_TOKEN} \ +# evermind -- we don't want any release entries there --request POST \ +# evermind -- we don't want any release entries there --silent \ +# evermind -- we don't want any release entries there --data @- \ +# evermind -- we don't want any release entries there https://api.github.com/repos/${GITHUB_USER}/${L_REPO}/releases < $TEMPFILE + mv $TEMPFILE $JSON_FILE + + create_tagged_release "$BNP_R_MGR_REPO" "$L_BRANCH" "\"version\": \"$VERSION_NAME\"" +} + +detect_build_tools_version() { + ls /usr/local/lib/android/sdk/build-tools/ | tail -n 1 +} + +BUILD_TOOLS_VERSION="${BUILD_TOOLS_VERSION:-$(detect_build_tools_version)}" + +AAPT=$ANDROID_HOME/build-tools/$BUILD_TOOLS_VERSION/aapt + +URL_PREFIX="https://github.com/${GITHUB_USER}/${RELEASE_REPO}/releases/download/${TAG}" +URL="$URL_PREFIX/BraveNewPipe_${TAG}.apk" +URL_CONSCRYPT="$URL_PREFIX/BraveNewPipe_conscrypt_${TAG}.apk" +URL_LEGACY="$URL_PREFIX/BraveNewPipe_legacy_${TAG}.apk" +VERSION_NAME=${TAG/v/} +VERSION_CODE="$($AAPT d badging $APK_FILE | grep -Po "(?<=\sversionCode=')([0-9.-]+)")" + +TEMPFILE="$(mktemp -p /tmp -t sdflhXXXXXXXXX)" +JSON_FILE=/tmp/${BNP_R_MGR_REPO}/api/data.json + +# We have two different json files for now: +# The first is used within the flavors brave and braveConscrypt +# and the second is used in braveLegacy. The json files +# are stored in the same repo but in different branches. +# We call kitkat stuff first as each call tags and delete same exising +# tags before and we want the master branch to have the actual tag. +create_json_file_and_create_tagged_release "kitkat" "$URL_LEGACY" "$URL_LEGACY" +create_json_file_and_create_tagged_release "master" "$URL" "$URL_CONSCRYPT" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 748f3dc249..202b67c704 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,11 +47,109 @@ jobs: distribution: "temurin" cache: 'gradle' + # - name: Rename strings from NewPipe to BraveNewPipe + # run: ./gradlew bravify + + # - name: Build brave flavor + # run: ./gradlew assembleBraveDebug + + # - name: Build braveConscrypt flavor + # run: ./gradlew assembleBraveConscryptDebug + + # - name: Prepare for building braveLegacy flavor + # run: ./gradlew prepareLegacyFlavor + + # - name: Build braveLegacy flavor + # run: ./gradlew assembleBraveLegacyDebug + + # - name: Unprepare for building braveLegacy flavor + # run: ./gradlew unPrepareLegacyFlavor + - name: Build debug APK and run jvm tests - run: ./gradlew assembleDebug lintDebug testDebugUnitTest --stacktrace -DskipFormatKtlint + run: ./gradlew assembleDebug testDebugUnitTest --stacktrace -DskipFormatKtlint + + # lintDebug gives error: ask 'lintDebug' is ambiguous in root project 'LastPipeBender' and its subprojects. Candidates are: 'lintAnalyzeBraveConscryptDebug', 'lintAnalyzeBraveDebug', 'lintAnalyzeBraveLegacyDebug', 'lintAnalyzeSponsorblockDebug', 'lintBraveConscryptDebug', 'lintBraveDebug', 'lintBraveLegacyDebug', 'lintFixBraveConscryptDebug', 'lintFixBraveDebug', 'lintFixBraveLegacyDebug', 'lintFixSponsorblockDebug', 'lintReportBraveConscryptDebug', 'lintReportBraveDebug', 'lintReportBraveLegacyDebug', 'lintReportSponsorblockDebug', 'lintSponsorblockDebug'. + # run: ./gradlew assembleDebug lintDebug testDebugUnitTest --stacktrace -DskipFormatKtlint - name: Upload APK uses: actions/upload-artifact@v4 with: name: app path: app/build/outputs/apk/debug/*.apk + # path: app/build/outputs/apk/brave*/debug/*.apk + + # - name: Build debug APK and run jvm tests + # run: ./gradlew assembleBraveDebug lintBraveDebug testBraveDebugUnitTest --stacktrace -DskipFormatKtlint + + # test-android: + # # macos has hardware acceleration. See android-emulator-runner action + # runs-on: macos-latest + # timeout-minutes: 20 + # strategy: + # matrix: + # include: + # - api-level: 21 + # target: default + # arch: x86 + # - api-level: 33 + # target: google_apis # emulator API 33 only exists with Google APIs + # arch: x86_64 + + # permissions: + # contents: read + + # steps: + # - uses: actions/checkout@v4 + + # - name: set up JDK 17 + # uses: actions/setup-java@v4 + # with: + # java-version: 17 + # distribution: "temurin" + # cache: 'gradle' + + # - name: Run android tests + # uses: reactivecircus/android-emulator-runner@v2 + # with: + # api-level: ${{ matrix.api-level }} + # target: ${{ matrix.target }} + # arch: ${{ matrix.arch }} + # script: ./gradlew connectedCheck --stacktrace + + # - name: Upload test report when tests fail # because the printed out stacktrace (console) is too short, see also #7553 + # uses: actions/upload-artifact@v4 + # if: failure() + # with: + # name: android-test-report-api${{ matrix.api-level }} + # path: app/build/reports/androidTests/connected/** + +# sonar: +# runs-on: ubuntu-latest +# +# permissions: +# contents: read +# +# steps: +# - uses: actions/checkout@v4 +# with: +# fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis +# +# - name: Set up JDK 17 +# uses: actions/setup-java@v4 +# with: +# java-version: 17 +# distribution: "temurin" +# cache: 'gradle' +# +# - name: Cache SonarCloud packages +# uses: actions/cache@v4 +# with: +# path: ~/.sonar/cache +# key: ${{ runner.os }}-sonar +# restore-keys: ${{ runner.os }}-sonar +# +# - name: Build and analyze +# env: +# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any +# SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} +# run: ./gradlew build sonar --info diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 888e5d8a16..37b0c9cf3b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -60,3 +60,209 @@ jobs: name: reports path: '*/build/reports' if: ${{ always() }} +# ### BRAVE NEWPIPE RELEASE WORKFLOW +# name: Android Build Release Workflow + +# on: +# push: +# #branches: [ dev ] +# tags: +# - '*' + + +# jobs: +# build: +# runs-on: ubuntu-latest + +# steps: +# - uses: actions/checkout@v2 +# - uses: gradle/wrapper-validation-action@v1 + +# - name: create and checkout branch +# # push events already checked out the branch +# if: github.event_name == 'pull_request' +# run: git checkout -B ${{ github.head_ref }} + +# #- name: restoreReleaseKeystore +# # run: | +# # echo "${{ secrets.RELEASE_KEYSTORE }}" -d -o release.keystore release.keystore.asc +# # gpg --batch --passphrase "${{ secrets.RELEASE_KEYSTORE_GPG }}" -d -o release.keystore release.keystore.asc + +# - name: generate ChangeLog +# run: | +# git show --no-patch HEAD --format='%B' > customChangeLogFile +# - name: set up JDK 17 +# uses: actions/setup-java@v3 +# with: +# java-version: 17 +# distribution: "temurin" + +# - name: Cache Gradle dependencies +# uses: actions/cache@v2 +# with: +# path: ~/.gradle/caches +# key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }} +# restore-keys: ${{ runner.os }}-gradle + +# - name: Rename strings from NewPipe to BraveNewPipe +# run: ./gradlew bravify + +# - name: Build brave flavor +# run: ./gradlew assembleBraveRelease + +# - name: Build braveConscrypt flavor +# run: ./gradlew assembleBraveConscryptRelease + +# - name: Prepare for building braveLegacy flavor +# run: ./gradlew prepareLegacyFlavor + +# - name: Build braveLegacy flavor +# run: ./gradlew assembleBraveLegacyRelease + +# - name: Setup build tool version variable +# shell: bash +# run: | +# BUILD_TOOL_VERSION=$(ls /usr/local/lib/android/sdk/build-tools/ | tail -n 1) +# echo "BUILD_TOOL_VERSION=$BUILD_TOOL_VERSION" >> $GITHUB_ENV +# echo Last build tool version is: $BUILD_TOOL_VERSION + +# - name: Sign app APK brave +# uses: r0adkll/sign-android-release@v1 +# # ID used to access action output +# id: sign_app +# with: +# releaseDirectory: app/build/outputs/apk/brave/release +# signingKeyBase64: ${{ secrets.RELEASE_KEYSTORE }} +# #alias: ${{ secrets.ALIAS }} +# alias: alias_name +# keyStorePassword: ${{ secrets.RELEASE_KEYSTORE_PASS }} +# #keyPassword: ${{ secrets.RELEASE_KEYSTORE_PASS }} +# env: +# BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOL_VERSION }} + +# - name: Sign app APK braveConscrypt +# uses: r0adkll/sign-android-release@v1 +# # ID used to access action output +# id: sign_app_conscrypt +# with: +# releaseDirectory: app/build/outputs/apk/braveConscrypt/release +# signingKeyBase64: ${{ secrets.RELEASE_KEYSTORE }} +# #alias: ${{ secrets.ALIAS }} +# alias: alias_name +# keyStorePassword: ${{ secrets.RELEASE_KEYSTORE_PASS }} +# #keyPassword: ${{ secrets.RELEASE_KEYSTORE_PASS }} +# env: +# BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOL_VERSION }} + +# - name: Sign app APK braveLegacy +# uses: r0adkll/sign-android-release@v1 +# # ID used to access action output +# id: sign_app_legacy +# with: +# releaseDirectory: app/build/outputs/apk/braveLegacy/release +# signingKeyBase64: ${{ secrets.RELEASE_KEYSTORE }} +# #alias: ${{ secrets.ALIAS }} +# alias: alias_name +# keyStorePassword: ${{ secrets.RELEASE_KEYSTORE_PASS }} +# #keyPassword: ${{ secrets.RELEASE_KEYSTORE_PASS }} +# env: +# BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOL_VERSION }} + +# - name: Upload brave artifact APK +# uses: actions/upload-artifact@v3 +# with: +# name: brave +# path: ${{steps.sign_app.outputs.signedReleaseFile}} +# #path: app/build/outputs/apk/brave/release/*.apk + +# - name: Upload braveConscrypt artifact APK +# uses: actions/upload-artifact@v3 +# with: +# name: braveConscrypt +# path: ${{steps.sign_app_conscrypt.outputs.signedReleaseFile}} + +# - name: Upload braveLegacy artifact APK +# uses: actions/upload-artifact@v3 +# with: +# name: braveLegacy +# path: ${{steps.sign_app_legacy.outputs.signedReleaseFile}} + +# # evermind: How to get just the tag name? -> https://github.community/t/how-to-get-just-the-tag-name/16241/11 +# - name: Branch name +# id: branch_name +# run: | +# echo ::set-output name=SOURCE_NAME::${GITHUB_REF#refs/*/} +# echo ::set-output name=SOURCE_BRANCH::${GITHUB_REF#refs/heads/} +# echo ::set-output name=SOURCE_TAG::${GITHUB_REF#refs/tags/} +# # evermind +# - name: rename apk and create checksum +# id: renamed_apk +# env: +# SOURCE_NAME: ${{ steps.branch_name.outputs.SOURCE_NAME }} +# SOURCE_BRANCH: ${{ steps.branch_name.outputs.SOURCE_BRANCH }} +# SOURCE_TAG: ${{ steps.branch_name.outputs.SOURCE_TAG }} +# SIGNED_APK: ${{ steps.sign_app.outputs.signedReleaseFile }} +# SIGNED_APK_CONSCRYPT: ${{ steps.sign_app_conscrypt.outputs.signedReleaseFile }} +# SIGNED_APK_LEGACY: ${{ steps.sign_app_legacy.outputs.signedReleaseFile }} +# run: | +# echo ::set-output name=RENAMED_APK::${SIGNED_APK%/*}/BraveNewPipe_${SOURCE_TAG}.apk +# cp ${SIGNED_APK} ${SIGNED_APK%/*}/BraveNewPipe_${SOURCE_TAG}.apk +# cd ${SIGNED_APK%/*} +# sha256sum BraveNewPipe_${SOURCE_TAG}.apk > BraveNewPipe_${SOURCE_TAG}.apk.sha256 +# cd - +# echo ::set-output name=RENAMED_APK_CONSCRYPT::${SIGNED_APK_CONSCRYPT%/*}/BraveNewPipe_conscrypt_${SOURCE_TAG}.apk +# cp ${SIGNED_APK_CONSCRYPT} ${SIGNED_APK_CONSCRYPT%/*}/BraveNewPipe_conscrypt_${SOURCE_TAG}.apk +# cd ${SIGNED_APK_CONSCRYPT%/*} +# sha256sum BraveNewPipe_conscrypt_${SOURCE_TAG}.apk > BraveNewPipe_conscrypt_${SOURCE_TAG}.apk.sha256 +# cd - +# echo ::set-output name=RENAMED_APK_LEGACY::${SIGNED_APK_LEGACY%/*}/BraveNewPipe_legacy_${SOURCE_TAG}.apk +# cp ${SIGNED_APK_LEGACY} ${SIGNED_APK_LEGACY%/*}/BraveNewPipe_legacy_${SOURCE_TAG}.apk +# cd ${SIGNED_APK_LEGACY%/*} +# sha256sum BraveNewPipe_legacy_${SOURCE_TAG}.apk > BraveNewPipe_legacy_${SOURCE_TAG}.apk.sha256 +# cd - +# # evermind: how to autorelease?: https://github.com/marvinpinto/action-automatic-releases +# - name: auto release +# uses: "evermind-zz/action-automatic-releases@v1.2.1-evrmd" +# with: +# repo_token: "${{ secrets.GITHUB_TOKEN }}" +# prerelease: false +# changelog_file: "customChangeLogFile" +# files: | +# ${{ steps.renamed_apk.outputs.RENAMED_APK }} +# ${{ steps.renamed_apk.outputs.RENAMED_APK }}.sha256 +# ${{ steps.renamed_apk.outputs.RENAMED_APK_CONSCRYPT }} +# ${{ steps.renamed_apk.outputs.RENAMED_APK_CONSCRYPT }}.sha256 +# ${{ steps.renamed_apk.outputs.RENAMED_APK_LEGACY }} +# ${{ steps.renamed_apk.outputs.RENAMED_APK_LEGACY }}.sha256 +# id: "automatic_releases" +# - name: "Automatically update json api data repository" +# env: +# GITHUB_SUPER_TOKEN: ${{ secrets.MY_GITHUB_SUPER_TOKEN }} +# RENAMED_APK: ${{ steps.renamed_apk.outputs.RENAMED_APK }} +# BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOL_VERSION }} +# run: | +# ./.github/scripts/brave-new-pipe-releast-actions.sh "$AUTOMATIC_RELEASES_TAG" "${RENAMED_APK}" + +# # evermind - name: pack all app dir +# # evermind run: tar czpf apper.tgz app +# # evermind - name: Upload app dir artifact more stuff +# # evermind uses: actions/upload-artifact@v2 +# # evermind with: +# # evermind name: appDir +# # evermind path: apper.tgz +# #- name: upload artefact to App Center +# # uses: wzieba/AppCenter-Github-Action@v1 +# # with: +# # appName: ronakukani/Github-Actions-Demo +# # token: ${{secrets.APP_CENTER_TOKEN}} +# # group: Testers +# # file: app/build/outputs/apk/release/app-release.apk +# # notifyTesters: true +# # debug: false +# # releaseNotes: "here is your release note" + +# #- name: Send message to ms teams +# # uses: dhollerbach/github-action-send-message-to-ms-teams@1.0.10 +# # with: +# # webhook: 'Here is your Microsoft Teams Webhook URL' +# # message: 'Here is your message' diff --git a/README.md b/README.md deleted file mode 100644 index 86217113a7..0000000000 --- a/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# The Last Pipe Bender -> NewPipe + PipePipe + BraveNewPipe + Tubular - -# Forks -- [x] NewPipe -- [x] Tubular -- [ ] BraveNewPipe -- [ ] PipePipe \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index 8efe0eb25c..6b22f52ba9 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -63,6 +63,63 @@ android { } } + // use productFlavors to keep the name/version changes AFAP for BraveNewPipe + // more separate in hope of not getting to many merge conflicts + flavorDimensions 'default' + // productFlavors { + // // the amount of trailing zeros depends on the amount of digits the + // // defaultConfig.versionCode has -> we just prepend our increasing + // // versionCode before those zeros. + // def braveVersionCode = 29000 + // // -> our versionName will be added as suffix to defaultConfig.versionName + // // We use major.minor.patch + // def braveVersionName = "2.1.10" + + // sponsorblock { // only for strings of sponsorblock stuff + // dimension 'default' + // } + + // brave { + // dimension 'default' + // applicationId "com.github.bravenewpipe" + // resValue "string", "app_name", "BraveNewPipe" + // versionCode defaultConfig.versionCode + braveVersionCode + // versionName "${defaultConfig.versionName}-${braveVersionName}" + // android.sourceSets.brave.res.srcDirs = [ 'src/brave/res', android.sourceSets.sponsorblock.res.srcDirs ] + // } + + // braveConscrypt { + // dimension 'default' + // applicationId "com.github.bravenewpipe" + // resValue "string", "app_name", "BraveNewPipe" + // versionCode defaultConfig.versionCode + braveVersionCode + // versionName "${defaultConfig.versionName}-${braveVersionName}" + // android.sourceSets.braveConscrypt.res.srcDirs = android.sourceSets.brave.res.srcDirs + + // dependencies { + // implementation 'org.conscrypt:conscrypt-android:2.5.2' + // } + // } + + // braveLegacy { + // dimension 'default' + // applicationId "com.github.bravenewpipe.kitkat" + // resValue "string", "app_name", "BraveNewPipe Kitkat" + // versionCode defaultConfig.versionCode + braveVersionCode + // versionName "${defaultConfig.versionName}-${braveVersionName}" + // android.sourceSets.braveLegacy.res.srcDirs = [ 'src/braveLegacy/res', android.sourceSets.braveConscrypt.res.srcDirs ] + + // multiDexEnabled true + // minSdk 19 + + // dependencies { + // implementation 'androidx.multidex:multidex:2.0.1' + // implementation 'org.conscrypt:conscrypt-android:2.5.2' + // implementation "com.github.evermind-zz.OsExt:osext-stat:1.0.1" + // } + // } + // } + lint { checkReleaseBuilds false // Or, if you prefer, you can continue to check for errors in release builds, @@ -148,6 +205,7 @@ tasks.register('runCheckstyle', Checkstyle) { exclude '**/R.java' exclude '**/BuildConfig.java' exclude 'main/java/us/shandian/giga/**' + exclude 'braveLegacy/java/us/shandian/giga/**' classpath = configurations.checkstyle @@ -181,10 +239,10 @@ tasks.register('formatKtlint', JavaExec) { } afterEvaluate { - if (!System.properties.containsKey('skipFormatKtlint')) { - preDebugBuild.dependsOn formatKtlint - } - preDebugBuild.dependsOn runCheckstyle, runKtlint +// if (!System.properties.containsKey('skipFormatKtlint')) { +// preBraveDebugBuild.dependsOn formatKtlint +// } +// preBraveDebugBuild.dependsOn runCheckstyle, runKtlint } sonar { @@ -347,3 +405,28 @@ project.afterEvaluate { } } } + +// keep the changed dependencies for BraveNewPipe more +// separate in hope of not getting to many merge conflicts +def okHttpVersion = "4.12.0" +// for JavaNetCookieJar see https://github.com/bravenewpipe/NewPipeExtractor/issues/123 + +// BRAVE NEWPIPE CONFLICT +// project.getDependencies().implementation("com.squareup.okhttp3:okhttp-urlconnection:${okHttpVersion}") +// configurations.all { +// exclude group: 'com.github.TeamNewPipe', module: 'NewPipeExtractor' + +// project.getDependencies().implementation("com.github.bravenewpipe:NewPipeExtractor:v0.24.0-2.1.10") + +// if (it.getName().contains("braveLegacy") || it.getName().contains("BraveLegacy")) { +// resolutionStrategy.dependencySubstitution { +// substitute module("com.github.TeamNewPipe:NoNonsense-FilePicker") using module("com.github.bravenewpipe:NoNonsense-FilePicker:21d5c57") because "we need Sdk 19 support" +// okHttpVersion= "3.12.13" +// substitute module("com.squareup.okhttp3:okhttp") using module("com.squareup.okhttp3:okhttp:${okHttpVersion}") because "we need Sdk 19 support" +// substitute module("com.squareup.okhttp3:okhttp-urlconnection") using module("com.squareup.okhttp3:okhttp-urlconnection:${okHttpVersion}") because "we need Sdk 19 support" +// } +// } +// } + +// replace NewPipe with BraveNewPipe in all strings.xml +// apply from: 'replace-newpipe-with-bravenewpipe-strings.gradle' diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 3fa4590966..18eecdb02a 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -36,6 +36,10 @@ ## For some reason NotificationModeConfigFragment wasn't kept (only referenced in a preference xml) -keep class org.schabi.newpipe.settings.notifications.** { *; } +# conscrypt rules (where not needed on 2.4.0) +-dontwarn com.android.org.conscrypt.SSLParametersImpl +-dontwarn org.apache.harmony.xnet.provider.jsse.SSLParametersImpl + ## TODO: figure out why we need these here to get a successful release build -dontwarn java.beans.BeanDescriptor -dontwarn java.beans.BeanInfo diff --git a/app/replace-newpipe-with-bravenewpipe-strings.gradle b/app/replace-newpipe-with-bravenewpipe-strings.gradle new file mode 100644 index 0000000000..0958acd334 --- /dev/null +++ b/app/replace-newpipe-with-bravenewpipe-strings.gradle @@ -0,0 +1,407 @@ + +import static groovy.io.FileType.FILES + +// ************************* +// * This gradle script should be included in the build.gradle by +// * using: apply from: 'replace-newpipe-with-bravenewpipe-strings.gradle' +// ************************* +// * It will replace all NewPipe occurrences with BraveNewPipe in any +// * strings.xml file. But some string references we do not want to change. +// * Therefore they are listed in the 'doNotReplaceLinesContaining' array. +// ************************* + +// begin -- vars and helper function +// array of strings that contain NewPipe but should not be replaced +def doNotReplaceLinesContaining = [ + 'name="donation_encouragement"', + 'name="contribution_encouragement"', + 'name="website_encouragement"', + 'name="brave_about_fork"' +] + +def shouldWeReplaceStuffInThisLine = { line -> + for (pattern in doNotReplaceLinesContaining) { + if (line.contains(pattern)) { + return false + } + } + return true +} + +def cleanupDirBefore = { dir -> + delete "${dir}" +} + +ext.copyStringsXmlFiles = { dir, tmpTargetDir -> + copy { + from(dir) + include '**/strings.xml' + filteringCharset = 'UTF-8' + filter { + line -> + if (shouldWeReplaceStuffInThisLine(line)) { + line + .replace('NewPipe', 'BraveNewPipe') + .replace('Newpipe', 'BraveNewPipe') + .replace('নিউপাইপ', 'সাহসী নিউপাইপ') // bn (bengali) + .replace('نیوپایپ', 'لوله جدید شجاع') // fa, czk (farsi) + } else { + line + } + } + into("${tmpTargetDir}") + } +} + +ext.copyNonStringsXmlFiles = { dir, tmpTargetDir -> + copy { + from(dir) + exclude '**/strings.xml' + into("${tmpTargetDir}") + } +} + +// the return value points to the new srcDir containing the modified files +ext.copyFilesAndReplaceStrings = { dir, tmpTargetDir -> + println "[BraveNewPipe string replacing] source dir: " + dir + println "[BraveNewPipe string replacing] target dir: " + tmpTargetDir + copyStringsXmlFiles(dir, tmpTargetDir) + copyNonStringsXmlFiles(dir, tmpTargetDir) + return "${tmpTargetDir}" +} + +ext.copyFiles = { dir, tmpTargetDir -> + copy { + from(dir) + into("${tmpTargetDir}") + } +} + +// replace variables content for specific files +ext.alterFilesAndVerify = { targetDir, isTest -> + + if (targetDir.contains('src/main/java') or isTest) { // only look into the 'main' variant + replaceAndVerify('s', true, targetDir, + /* filename: */ 'org/schabi/newpipe/error/ErrorActivity.java', + /* match: */ 'ERROR_EMAIL_ADDRESS = "crashreport@newpipe.schabi.org"', + /* replace: */ 'ERROR_EMAIL_ADDRESS = "crashreport@gmx.com"', + /* verify: */ 'crashreport@gmx.com') + + replaceAndVerify('m', false, targetDir, + /* filename: */ 'org/schabi/newpipe/error/ErrorActivity.java', + /* match: */ '(public static final String ERROR_GITHUB_ISSUE_URL.*\n[^=]*=)[^;]*', + /* replace: */ '\\1 "https://github.com/bravenewpipe/NewPipeExtractor/issues"', + /* verify: */ 'https://github.com/bravenewpipe/NewPipeExtractor/issues') + + replaceAndVerify('s', true, targetDir, + /* filename: */ 'org/schabi/newpipe/util/ReleaseVersionUtil.kt', + /* match: */ '"B0:2E:90:7C:1C:D6:FC:57:C3:35:F0:88:D0:8F:50:5F:94:E4:D2:15"', + /* replace: */ '"C3:96:13:CD:13:92:3F:37:EE:B6:9F:7A:0D:EA:7C:70:E0:7A:73:D8"', + /* verify: */ '"C3:96:13:CD:13:92:3F:37:EE:B6:9F:7A:0D:EA:7C:70:E0:7A:73:D8"') + + replaceAndVerify('m', false, targetDir, + /* filename: */ 'org/schabi/newpipe/NewVersionWorker.kt', + /* match: */ '(private const val NEWPIPE_API_URL =).*\n.*', + /* replace: */ '\\1\n "https://raw.githubusercontent.com/bravenewpipe/bnp-r-mgr/master/api/data.json"', + /* verify: */ '"https://raw.githubusercontent.com/bravenewpipe/bnp-r-mgr/master/api/data.json"') + } + + return "${targetDir}" +} + +ext.replaceAndVerify = { flags, byline, dir, fileName, match, replace, verify -> + assert file(dir + '/' + fileName).exists() + + // check if file is already changed + def lines2 = new File(dir + '/' + fileName).readLines() + def result2 = lines2.find { it.contains(verify) } + if (result2 != null) { + println "[BraveNewPipe] already changed $match in $fileName" + // already changed so return + return + } + + println "[BraveNewPipe] string replacing in file: " + fileName + ' [What:]' + match + ant.replaceregexp( + match:match, + replace:replace, + flags:flags, + byline:byline) { + fileset(dir: dir, includes: fileName) + } + + // verify that it really got changed + def lines = new File(dir + '/' + fileName).readLines() + def result = lines.find { it.contains(verify) } + //println result + assert result != null : "No match for '${match} in ${fileName}" +} +// end -- vars and helper function + +// * * * * * * * * * * * * +// Do the actual replacing. +// * * * * * * * * * * * * +// source: https://stackoverflow.com/questions/40843740/replace-word-in-strings-xml-with-gradle-for-a-buildtype/57533688#57533688 +// https://michd.me/jottings/gradle-variant.getx-is-obsolete/ +android.applicationVariants.all { variant -> + variant.mergeResourcesProvider.getOrNull()?.doFirst { + variant.sourceSets.each { sourceSet -> + sourceSet.res.srcDirs = sourceSet.res.srcDirs.collect { dir -> + def relDir = relativePath(dir) + def tmpTargetDir = "${buildDir}/tmp/${variant.dirName}/${relDir}" + cleanupDirBefore(tmpTargetDir) + return copyFilesAndReplaceStrings(dir, tmpTargetDir) + } + } + } +} + +// only for DEBUGGING of copyFilesAndReplaceStrings() or +// alterFilesAndVerify() +task testReplacingStrings() { + // -> comment the 'return' statement to actually start debugging + return + + println "[TESTING BraveNewPipe string replacing]" + + // modify .{xml} files + def relativeDirFile = 'src/main/res' + def sourceDir = "${rootDir}/app/${relativeDirFile}/" + def targetDir = "${buildDir}/tmp/${relativeDirFile}/test_output" + cleanupDirBefore(targetDir) + copyFilesAndReplaceStrings(sourceDir, targetDir) +} + +ext.replaceNewPipeWithBraveNewPipeStrings = { + + println "[BraveNewPipe string replacing]" + + // modify .{xml} files + def relativeDirFile = 'src/main/res' + def sourceDir = "${rootDir}/app/${relativeDirFile}/" + def targetDir = "${buildDir}/tmp/${relativeDirFile}/test_output" + copyStringsXmlFiles(sourceDir, targetDir) + copyFiles(targetDir, sourceDir) +} + +task bravify() { + group = 'brave' + description = 'replaces string NewPipe with BraveNewPipe' + + doLast { + replaceNewPipeWithBraveNewPipeStrings() + } +} + +// Patch NewPipe to use BraveNewPipe's: +// - support email address +// - the update json data URL +// - the apk signature +// This task (if enabled) will run on the current code base and +// does this job for you automatically. To be re-run if needed. +// -- evermind -- +task testReplaceMailandJsonandSignature() { + group = "braveTest" + + doLast { + // modify .{java,kt} files + def relativeDirFile = 'src/main/java' + def sourceDir = "${rootDir}/app/${relativeDirFile}/" + alterFilesAndVerify(sourceDir, true) + } +} + +//----------------------------------------------------------------------------------------- +// ############ begin -- section only relevant for braveLegacy flavor building############# +//----------------------------------------------------------------------------------------- +// We have some same sourcecode files in 'main' and in 'braveLegacy' flavor. +// To make it compile we need to remove the 'main' files first. Before calling: +// # gradle assembleBraveLegacy{Debug,Release,Whatever} +// you have to call +// # gradle prepareLegacyFlavor +// and afterwards to restore the files (in case you want to build another flavor: +// # gradle unPrepareLegacyFlavor +ext.prepareFilesForLegacy = { doRestore -> + + def relativeSourceDir = 'src/braveLegacy/java' + def sourceDir = "${rootDir}/app/${relativeSourceDir}/" + def tempDir = "${rootDir}/tmp-legacy" + + new File(sourceDir).eachFileRecurse(FILES) { + if ((!it.name.startsWith('Brave')) && (it.name.endsWith(".kt") || it.name.endsWith(".java"))) { + def targetParentDir = it.parent.replace("braveLegacy", "main") + def fileName = it.name + def originFilePath = targetParentDir + "/" + fileName + def targetFileName = tempDir + "/" + fileName + + if (doRestore) { + println "[BraveLegacy] move: " + targetFileName + " -> " + originFilePath + ant.move(file: targetFileName, tofile: originFilePath) + } else { // hide the files from the compiler + println "[BraveLegacy] move " + originFilePath + " -> " + tempDir + ant.move(file: originFilePath, toDir: tempDir) + } + } + } +} + +ext.alterNewVersionWorkerForLegacy = { targetDir, isTest, infix -> + + if (targetDir.contains('src/main/java') or isTest) { // only look into the 'main' variant + replaceAndVerify('m', false, targetDir, + /* filename: */ 'org/schabi/newpipe/NewVersionWorker.kt', + /* match: */ '(private const val NEWPIPE_API_URL =).*\n.*', + /* replace: */ '\\1\n "https://raw.githubusercontent.com/bravenewpipe/bnp-r-mgr/' + infix + '/api/data.json"', + /* verify: */ '"https://raw.githubusercontent.com/bravenewpipe/bnp-r-mgr/' + infix + '/api/data.json"') + } + + return "${targetDir}" +} + +ext.replaceNewVersionJsonUrl = { infix -> + def relativeDirFile = 'src/main/java' + def sourceDir = "${rootDir}/app/${relativeDirFile}/" + alterNewVersionWorkerForLegacy(sourceDir, false, infix) +} + +task prepareLegacyFlavor() { + group = 'brave' + description = "move duplicated files from 'main' to tmp dir" + doLast { + prepareFilesForLegacy(false) + replaceNewVersionJsonUrl("kitkat") + legacyFlavorGenerateDrawableNightForMainSettings() + } +} + +task unprepareLegacyFlavor() { + group = 'brave' + description = "move duplicated files from tmp dir 'main'" + doLast { + prepareFilesForLegacy(true) + replaceNewVersionJsonUrl("master") + removeGeneratedDrawableNightForMainSettings() + } +} + +task testReplaceNewVersionUrl() { + group = "braveTest" + description = "change to legacy new version URL" + doLast { + replaceNewVersionJsonUrl("kitkat") + } +} + +task testUnReplaceNewVersionUrl() { + group = "braveTest" + description = "change to master new version URL" + doLast { + replaceNewVersionJsonUrl("master") + } +} + +// -- begin create night version of main_settings page's icons +ext.resDir = { flavor -> + def relativeResDir = 'src/' + flavor + '/res' + return "${rootDir}/app/${relativeResDir}/" +} + +ext.legacyResDir = { + return resDir('braveLegacy') +} + +ext.legacyResDrawableNightDir = { + def resDir = legacyResDir() + return new File(resDir, 'drawable-night') +} + +ext.generateOutputFileName = { drawableXml, destDir -> + def inputFileBaseName = file(drawableXml).getName() + def outputFileName = destDir.getAbsolutePath() + '/' + inputFileBaseName + return outputFileName +} + +ext.generateLegacyDrawableNightVersion = { drawableXml, destDir -> + destDir.mkdirs() + + def xml = new XmlParser(false, false).parse(drawableXml) + def newTintColor = "#FFFFFF" + xml.@'android:tint' = newTintColor + def newXmlOutput = groovy.xml.XmlUtil.serialize(xml) + + def outputFileName = generateOutputFileName(drawableXml, destDir) + def nightVersionXmlFile = new File(outputFileName) + nightVersionXmlFile.write(newXmlOutput) + + println '[BraveLegacy] generate drawable-night xml file: ' + file(drawableXml).getName() +} + +ext.generateListOfMainSettingsDrawableIconFileNames = { + def resDir = resDir('main') + def mainSettingsXmlFile = file(resDir + '/xml/main_settings.xml') + def xml = new XmlParser(false, false).parse(mainSettingsXmlFile) + def drawablesList = [] + + xml.PreferenceScreen.each { prefScreen -> + drawablesList.add(prefScreen.attribute('android:icon').replace('@', resDir) + ".xml") + } + + return drawablesList +} + +ext.removeLegacyDrawableNightVersion = { drawableXml, destDir -> + if (destDir.exists()) { + + def xmlIconFile = file(generateOutputFileName(drawableXml, destDir)) + if (xmlIconFile.exists()) { + def isDeleted = xmlIconFile.delete() + println '[BraveLegacy] drawable-night xml file: ' + xmlIconFile.getName() + " deleted?: " + isDeleted + } + } +} + +ext.legacyFlavorGenerateDrawableNightForMainSettings = { + def destDir = legacyResDrawableNightDir() + println '[BraveLegacy] generate drawable-night xml file into: ' + destDir + + def drawablesList = generateListOfMainSettingsDrawableIconFileNames() + + drawablesList.forEach { drawableXml -> + generateLegacyDrawableNightVersion(drawableXml, destDir) + } +} + +ext.removeGeneratedDrawableNightForMainSettings = { + def destDir = legacyResDrawableNightDir() + println '[BraveLegacy] generate drawable-night xml file into: ' + destDir + + def drawablesList = generateListOfMainSettingsDrawableIconFileNames() + + drawablesList.forEach { drawableXml -> + removeLegacyDrawableNightVersion(drawableXml, destDir) + } + + if (destDir.isDirectory() && destDir.list().length == 0) { + destDir.delete() + } +} + +task testCreateDrawableNightXmls { + group = "braveTest" + description = "generate drawable-night icons for settings main page" + + doLast { + legacyFlavorGenerateDrawableNightForMainSettings() + } +} + +task testUnCreateDrawableNightXmls { + group = "braveTest" + description = "remove previous created drawable-night icons for settings main page" + + doLast { + removeGeneratedDrawableNightForMainSettings() + } +} +// -- end create night version of main_settings page's icons +// ############ end -- section only relevant for braveLegacy flavor building############# diff --git a/app/src/brave/java/org/schabi/newpipe/BraveApp.java b/app/src/brave/java/org/schabi/newpipe/BraveApp.java new file mode 100644 index 0000000000..af21c5d8fa --- /dev/null +++ b/app/src/brave/java/org/schabi/newpipe/BraveApp.java @@ -0,0 +1,6 @@ +package org.schabi.newpipe; + +import android.app.Application; + +public class BraveApp extends Application { +} diff --git a/app/src/brave/java/org/schabi/newpipe/settings/BraveVideoAudioSettingsBaseFragment.java b/app/src/brave/java/org/schabi/newpipe/settings/BraveVideoAudioSettingsBaseFragment.java new file mode 100644 index 0000000000..abde4ec72b --- /dev/null +++ b/app/src/brave/java/org/schabi/newpipe/settings/BraveVideoAudioSettingsBaseFragment.java @@ -0,0 +1,4 @@ +package org.schabi.newpipe.settings; + +public abstract class BraveVideoAudioSettingsBaseFragment extends BraveBasePreferenceFragment { +} diff --git a/app/src/brave/res/drawable-anydpi-v24/ic_newpipe_triangle_white.xml b/app/src/brave/res/drawable-anydpi-v24/ic_newpipe_triangle_white.xml new file mode 100644 index 0000000000..4ea556dc2d --- /dev/null +++ b/app/src/brave/res/drawable-anydpi-v24/ic_newpipe_triangle_white.xml @@ -0,0 +1,31 @@ + + + + + + + diff --git a/app/src/brave/res/drawable-anydpi-v24/ic_newpipe_update.xml b/app/src/brave/res/drawable-anydpi-v24/ic_newpipe_update.xml new file mode 100644 index 0000000000..9e98a35a76 --- /dev/null +++ b/app/src/brave/res/drawable-anydpi-v24/ic_newpipe_update.xml @@ -0,0 +1,45 @@ + + + + + + + + + + diff --git a/app/src/brave/res/drawable-hdpi/ic_newpipe_triangle_white.png b/app/src/brave/res/drawable-hdpi/ic_newpipe_triangle_white.png new file mode 100644 index 0000000000..150461dcf4 Binary files /dev/null and b/app/src/brave/res/drawable-hdpi/ic_newpipe_triangle_white.png differ diff --git a/app/src/brave/res/drawable-hdpi/ic_newpipe_update.png b/app/src/brave/res/drawable-hdpi/ic_newpipe_update.png new file mode 100644 index 0000000000..59bbecf0a3 Binary files /dev/null and b/app/src/brave/res/drawable-hdpi/ic_newpipe_update.png differ diff --git a/app/src/brave/res/drawable-mdpi/ic_newpipe_triangle_white.png b/app/src/brave/res/drawable-mdpi/ic_newpipe_triangle_white.png new file mode 100644 index 0000000000..75b3d4c348 Binary files /dev/null and b/app/src/brave/res/drawable-mdpi/ic_newpipe_triangle_white.png differ diff --git a/app/src/brave/res/drawable-mdpi/ic_newpipe_update.png b/app/src/brave/res/drawable-mdpi/ic_newpipe_update.png new file mode 100644 index 0000000000..8e10a526f7 Binary files /dev/null and b/app/src/brave/res/drawable-mdpi/ic_newpipe_update.png differ diff --git a/app/src/brave/res/drawable-nodpi/newpipe_logo_nude_shadow.png b/app/src/brave/res/drawable-nodpi/newpipe_logo_nude_shadow.png new file mode 100644 index 0000000000..8ccb218296 Binary files /dev/null and b/app/src/brave/res/drawable-nodpi/newpipe_logo_nude_shadow.png differ diff --git a/app/src/brave/res/drawable-xhdpi/ic_newpipe_triangle_white.png b/app/src/brave/res/drawable-xhdpi/ic_newpipe_triangle_white.png new file mode 100644 index 0000000000..626878fa65 Binary files /dev/null and b/app/src/brave/res/drawable-xhdpi/ic_newpipe_triangle_white.png differ diff --git a/app/src/brave/res/drawable-xhdpi/ic_newpipe_update.png b/app/src/brave/res/drawable-xhdpi/ic_newpipe_update.png new file mode 100644 index 0000000000..c77e89ad22 Binary files /dev/null and b/app/src/brave/res/drawable-xhdpi/ic_newpipe_update.png differ diff --git a/app/src/brave/res/drawable-xxhdpi/ic_newpipe_triangle_white.png b/app/src/brave/res/drawable-xxhdpi/ic_newpipe_triangle_white.png new file mode 100644 index 0000000000..38a7983be4 Binary files /dev/null and b/app/src/brave/res/drawable-xxhdpi/ic_newpipe_triangle_white.png differ diff --git a/app/src/brave/res/drawable-xxhdpi/ic_newpipe_update.png b/app/src/brave/res/drawable-xxhdpi/ic_newpipe_update.png new file mode 100644 index 0000000000..f82b1ac043 Binary files /dev/null and b/app/src/brave/res/drawable-xxhdpi/ic_newpipe_update.png differ diff --git a/app/src/brave/res/drawable/splash_foreground.xml b/app/src/brave/res/drawable/splash_foreground.xml new file mode 100644 index 0000000000..417cc4ffaa --- /dev/null +++ b/app/src/brave/res/drawable/splash_foreground.xml @@ -0,0 +1,8 @@ + + + diff --git a/app/src/brave/res/mipmap-hdpi/ic_launcher.png b/app/src/brave/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000..25d97acb3d Binary files /dev/null and b/app/src/brave/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/brave/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/brave/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000..ad0f8ae2ff Binary files /dev/null and b/app/src/brave/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/app/src/brave/res/mipmap-mdpi/ic_launcher.png b/app/src/brave/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000..b9ea95463e Binary files /dev/null and b/app/src/brave/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/brave/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/brave/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000..6a1c59a76c Binary files /dev/null and b/app/src/brave/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/app/src/brave/res/mipmap-xhdpi/ic_launcher.png b/app/src/brave/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000..b86e00135b Binary files /dev/null and b/app/src/brave/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/brave/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/brave/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000..d9144609c6 Binary files /dev/null and b/app/src/brave/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/app/src/brave/res/mipmap-xhdpi/newpipe_tv_banner.png b/app/src/brave/res/mipmap-xhdpi/newpipe_tv_banner.png new file mode 100644 index 0000000000..1b63bac102 Binary files /dev/null and b/app/src/brave/res/mipmap-xhdpi/newpipe_tv_banner.png differ diff --git a/app/src/brave/res/mipmap-xxhdpi/ic_launcher.png b/app/src/brave/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000..e0351d5bfa Binary files /dev/null and b/app/src/brave/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/brave/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/brave/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000..5305791c87 Binary files /dev/null and b/app/src/brave/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/brave/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/brave/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000..30e00a6d53 Binary files /dev/null and b/app/src/brave/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/brave/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/brave/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000..cd99379121 Binary files /dev/null and b/app/src/brave/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/brave/res/values-bg/strings.xml b/app/src/brave/res/values-bg/strings.xml new file mode 100644 index 0000000000..ca4c373f6c --- /dev/null +++ b/app/src/brave/res/values-bg/strings.xml @@ -0,0 +1,5 @@ + + + Поради ограничителните политики на проекта екипът на NewPipe отказва да добавя платформи, които смята за обидни. Тази вилица (BraveNewPipe) няма да бъде толкова ограничаваща. Докато платформите работят в духа на свободното изразяване, те могат да бъдат интегрирани. Въпреки това платформите, които популяризират порнография или други унизителни неща, няма да бъдат интегрирани тук. Тази вилица ще се фокусира само върху интегрирането на други платформи. Пачовете, които не са подходящи, ще бъдат отхвърлени. Не се колебайте да предложите кои алтернативни платформи трябва да се поддържат. Всеки принос (разработка/проба/доклад за грешка) е добре дошъл. В момента има допълнителни платформи в сравнение с NewPipe: + За тази вилица + diff --git a/app/src/brave/res/values-cs/strings.xml b/app/src/brave/res/values-cs/strings.xml new file mode 100644 index 0000000000..5c3cf34675 --- /dev/null +++ b/app/src/brave/res/values-cs/strings.xml @@ -0,0 +1,5 @@ + + + Vzhledem k restriktivním zásadám projektu odmítá NewPipeTeam přidávat platformy, které považuje za urážlivé. Tato vidlice (BraveNewPipe) nebude tak omezující. Pokud platformy fungují v duchu svobody projevu, lze je integrovat. Nicméně platformy, které propagují pornografii nebo jiné ponižující věci, zde integrovány nebudou. Tento fork se zaměří pouze na integraci dalších platforem. Záplaty, které nejsou relevantní, budou prozatím odmítnuty. Neváhejte navrhnout, které alternativní platformy by měly být podporovány. Jakýkoli příspěvek (vývoj/test/hlášení chyb) je velmi vítán. V současné době další platformy ve srovnání s NewPipe: + O této vidlici + diff --git a/app/src/brave/res/values-da/strings.xml b/app/src/brave/res/values-da/strings.xml new file mode 100644 index 0000000000..13bd24c849 --- /dev/null +++ b/app/src/brave/res/values-da/strings.xml @@ -0,0 +1,5 @@ + + + På grund af restriktive projektpolitikker nægter NewPipeTeam at tilføje platforme, som de finder stødende. Denne gaffel (BraveNewPipe) vil ikke være så restriktiv. Så længe platformene fungerer i en ånd af ytringsfrihed, kan de integreres. Ikke desto mindre vil platforme, der promoverer pornografi eller andre nedværdigende ting, IKKE blive integreret her. Denne fork vil kun fokusere på at integrere andre platforme. Patches, der ikke er relevante, vil blive afvist indtil videre. Du er velkommen til at foreslå, hvilke alternative platforme der bør understøttes. Ethvert bidrag (udvikling/test/fejlrapport) er meget velkomment. I øjeblikket flere platforme i forhold til NewPipe: + Om denne gaffel + diff --git a/app/src/brave/res/values-de/strings.xml b/app/src/brave/res/values-de/strings.xml new file mode 100644 index 0000000000..0dc8763bfa --- /dev/null +++ b/app/src/brave/res/values-de/strings.xml @@ -0,0 +1,81 @@ + + + Aufgrund restriktiver Projektrichtlinien weigert sich das NewPipeTeam, Plattformen hinzuzufügen, die sie als anstößig empfinden. Dieser Fork (BraveNewPipe) wird nicht so restriktiv sein. Solange die Plattformen im Sinne der freien Meinungsäußerung arbeiten, können sie integriert werden. Dennoch werden Plattformen, die Pornographie oder andere entwürdigende Dinge fördern, hier NICHT integriert werden. Dieser Fork wird sich nur auf die Integration anderer Plattformen konzentrieren. Nicht relevante Patches werden vorerst abgelehnt. Du kannst gerne vorschlagen, welche alternativen Plattformen unterstützt werden sollten. Jeder Beitrag (Entwicklung/Test/Fehlerbericht) ist sehr willkommen. Derzeit zusätzliche Plattformen im Vergleich zu NewPipe: + Über diesen Fork + + + Kurz + Lang + Rumbles + Neueste + Kurz (0-5m) + Mittel (5-20m) + Lang (20m+) + Filmlänge (45m+) + Neuste zuerst + Älteste zuerst + + + + Sortierfilter + Inhaltsfilter + 10–30 min + 2–10 min + 360° + 3D + 4–20 min + 4K + Hinzugefügt + Künstler & Labels + Jederzeit + Aufsteigend + Creative Commons + Erstellungsdatum + Datum + Dauer + Eigenschaften + > 30 min + HD + HDR + Art + Letzte 30 Tage + Letzte 7 Tage + Letzte Stunde + Letztes Jahr + Länge + < 2 min + Lizenz + Standort + Lang (> 10 min) + Mittel (4–10 min) + Nein + Über 20 min + Vorheriger Tag + Vorige Stunde + Vergangener Monat + Vergangene Woche + Vergangenes Jahr + Erscheinungsdatum + Veröffentlicht + Gekauft + Bewertung + Relevanz + Sensibler Inhalt + SepiaSuche + Kurz (< 4 min) + Sortiert nach + Sortierung + Untertitel + Gewerblich nutzbar + Heute + Unter 4 min + Hochladedatum + Aufrufe + VOD-Videos + VR180 + Dieser Monat + Diese Woche + Dieses Jahr + Ja + + diff --git a/app/src/brave/res/values-el/strings.xml b/app/src/brave/res/values-el/strings.xml new file mode 100644 index 0000000000..ec5da751bd --- /dev/null +++ b/app/src/brave/res/values-el/strings.xml @@ -0,0 +1,5 @@ + + + Λόγω των περιοριστικών πολιτικών του έργου, η NewPipeTeam αρνείται να προσθέσει πλατφόρμες που θεωρεί προσβλητικές. Αυτό το πιρούνι (BraveNewPipe) δεν θα είναι τόσο περιοριστικό. Εφόσον οι πλατφόρμες λειτουργούν στο πνεύμα της ελεύθερης έκφρασης, μπορούν να ενσωματωθούν. Παρόλα αυτά, οι πλατφόρμες που προωθούν την πορνογραφία ή άλλα εξευτελιστικά πράγματα ΔΕΝ θα ενσωματωθούν εδώ. Αυτή η διακλάδωση θα επικεντρωθεί μόνο στην ενσωμάτωση άλλων πλατφορμών. Οι διορθώσεις που δεν είναι σχετικές θα απορριφθούν προς το παρόν. Μπορείτε να προτείνετε ποιες εναλλακτικές πλατφόρμες θα πρέπει να υποστηρίζονται. Οποιαδήποτε συνεισφορά (ανάπτυξη/δοκιμή/αναφορά σφαλμάτων) είναι πολύ ευπρόσδεκτη. Επί του παρόντος πρόσθετες πλατφόρμες σε σύγκριση με το NewPipe: + Σχετικά με αυτό το πιρούνι + diff --git a/app/src/brave/res/values-es/strings.xml b/app/src/brave/res/values-es/strings.xml new file mode 100644 index 0000000000..9eea9f1856 --- /dev/null +++ b/app/src/brave/res/values-es/strings.xml @@ -0,0 +1,5 @@ + + + Debido a las políticas restrictivas del proyecto, el NewPipeTeam se niega a añadir plataformas que considera objetables. Esta horquilla (BraveNewPipe) no será tan restrictiva. Mientras las plataformas funcionen con el espíritu de la libertad de expresión, pueden integrarse. Sin embargo, las plataformas que promueven la pornografía u otras cosas degradantes no serán integradas aquí. Esta bifurcación sólo se centrará en la integración de otras plataformas. Los parches que no sean relevantes serán rechazados por ahora. Siéntase libre de sugerir qué plataformas alternativas deberían ser apoyadas. Cualquier contribución (desarrollo/prueba/informe de errores) es muy bienvenida. Actualmente plataformas adicionales en comparación con NewPipe: + Sobre este tenedor + diff --git a/app/src/brave/res/values-et/strings.xml b/app/src/brave/res/values-et/strings.xml new file mode 100644 index 0000000000..5190a3d7a4 --- /dev/null +++ b/app/src/brave/res/values-et/strings.xml @@ -0,0 +1,5 @@ + + + Projekti piiravate põhimõtete tõttu keeldub NewPipeTeam lisamast platvorme, mida nad peavad solvavaks. See kahvel (BraveNewPipe) ei ole nii piirav. Niikaua kui platvormid töötavad vaba sõnavabaduse vaimus, saab neid integreerida. Siiski, platvormid, mis propageerivad pornograafiat või muid alandavaid asju, ei integreerita siia. See kahvel keskendub ainult teiste platvormide integreerimisele. Mittekohased parandused lükatakse esialgu tagasi. Võite vabalt soovitada, milliseid alternatiivseid platvorme tuleks toetada. Igasugune panus (arendus/test/veaaruanne) on väga teretulnud. Praegu täiendavad platvormid võrreldes NewPipe\'iga: + Selle kahvli kohta + diff --git a/app/src/brave/res/values-fi/strings.xml b/app/src/brave/res/values-fi/strings.xml new file mode 100644 index 0000000000..63dea5e280 --- /dev/null +++ b/app/src/brave/res/values-fi/strings.xml @@ -0,0 +1,5 @@ + + + Rajoittavien projektikäytäntöjen vuoksi NewPipeTeam kieltäytyy lisäämästä alustoja, jotka ovat heidän mielestään loukkaavia. Tämä haarukka (BraveNewPipe) ei ole niin rajoittava. Kunhan alustat toimivat sananvapauden hengessä, ne voidaan integroida. Siitä huolimatta alustoja, jotka edistävät pornografiaa tai muita halventavia asioita, ei EI integroida tänne. Tässä haarassa keskitytään vain muiden alustojen integrointiin. Laastarit, jotka eivät ole relevantteja, hylätään toistaiseksi. Voit vapaasti ehdottaa, mitä vaihtoehtoisia alustoja pitäisi tukea. Mikä tahansa panos (kehitys/testaus/vikailmoitus) on erittäin tervetullut. Tällä hetkellä ylimääräisiä alustoja verrattuna NewPipeen: + Tietoa tästä haarukasta + diff --git a/app/src/brave/res/values-fr/strings.xml b/app/src/brave/res/values-fr/strings.xml new file mode 100644 index 0000000000..c8bf017066 --- /dev/null +++ b/app/src/brave/res/values-fr/strings.xml @@ -0,0 +1,5 @@ + + + En raison des politiques restrictives du projet, la NewPipeTeam refuse d\'ajouter des plateformes qu\'elle juge répréhensibles. Cette fourche (BraveNewPipe) ne sera pas aussi restrictive. Tant que les plateformes fonctionnent dans l\'esprit de la libre expression, elles peuvent être intégrées. Néanmoins, les plateformes qui font la promotion de la pornographie ou d\'autres choses dégradantes ne seront PAS intégrées ici. Ce fork se concentrera uniquement sur l\'intégration d\'autres plateformes. Les correctifs qui ne sont pas pertinents seront rejetés pour l\'instant. N\'hésitez pas à suggérer les plates-formes alternatives qui devraient être prises en charge. Toute contribution (développement/test/rapport de bug) est la bienvenue. Actuellement, des plateformes supplémentaires par rapport à NewPipe: + A propos de cette fourche + diff --git a/app/src/brave/res/values-hu/strings.xml b/app/src/brave/res/values-hu/strings.xml new file mode 100644 index 0000000000..96b6c35d49 --- /dev/null +++ b/app/src/brave/res/values-hu/strings.xml @@ -0,0 +1,5 @@ + + + A NewPipeTeam a korlátozó projektirányelvek miatt nem hajlandó olyan platformokat felvenni, amelyeket sértőnek talál. Ez a villa (BraveNewPipe) nem lesz ennyire korlátozó. Amíg a platformok a szabad véleménynyilvánítás szellemében működnek, addig integrálhatók. Mindazonáltal, a pornográfiát vagy más megalázó dolgokat népszerűsítő platformok NEM lesznek ide integrálva. Ez a fork csak más platformok integrálására fog összpontosítani. A nem releváns javításokat egyelőre elutasítjuk. Nyugodtan javasolhatja, hogy mely alternatív platformokat kellene támogatni. Bármilyen hozzájárulást (fejlesztés/teszt/hibajelentés) nagyon szívesen fogadunk. Jelenleg további platformok a NewPipe-hez képest: + Erről a villáról + diff --git a/app/src/brave/res/values-it/strings.xml b/app/src/brave/res/values-it/strings.xml new file mode 100644 index 0000000000..2444e3cb43 --- /dev/null +++ b/app/src/brave/res/values-it/strings.xml @@ -0,0 +1,5 @@ + + + A causa delle politiche restrittive del progetto, il NewPipeTeam rifiuta di aggiungere piattaforme che ritiene offensive. Questo fork (BraveNewPipe) non sarà così restrittivo. Finché le piattaforme lavorano nello spirito della libera espressione, possono essere integrate. Tuttavia, le piattaforme che promuovono la pornografia o altre cose degradanti NON saranno integrate qui. Questo fork si concentrerà solo sull\'integrazione di altre piattaforme. Le patch che non sono rilevanti saranno rifiutate per ora. Sentitevi liberi di suggerire quali piattaforme alternative dovrebbero essere supportate. Qualsiasi contributo (sviluppo/test/segnalazione di bug) è molto gradito. Attualmente piattaforme aggiuntive rispetto a NewPipe: + Informazioni su questa forcella + diff --git a/app/src/brave/res/values-ja/strings.xml b/app/src/brave/res/values-ja/strings.xml new file mode 100644 index 0000000000..ffa9029755 --- /dev/null +++ b/app/src/brave/res/values-ja/strings.xml @@ -0,0 +1,5 @@ + + + プロジェクトの制限的なポリシーにより、NewPipeTeamは不快なプラットフォームの追加を拒否しています。今回のフォーク(BraveNewPipe)は、そのような制限はありません。表現の自由の精神に則ったプラットフォームであれば、統合することができます。しかし、ポルノやその他の下劣なものを促進するプラットフォームは、ここではNGです。今回のフォークでは、他のプラットフォームの統合にのみ焦点を当てます。関連性のないパッチはとりあえず却下されます。どのような代替プラットフォームをサポートすべきか、自由に提案してください。どのような貢献(開発、テスト、バグレポート)でも大歓迎です。現在、NewPipeに比べてプラットフォームが増えています。 + このフォークについて + diff --git a/app/src/brave/res/values-lt/strings.xml b/app/src/brave/res/values-lt/strings.xml new file mode 100644 index 0000000000..596081a156 --- /dev/null +++ b/app/src/brave/res/values-lt/strings.xml @@ -0,0 +1,5 @@ + + + Dėl griežtos projekto politikos "NewPipeTeam" atsisako pridėti platformas, kurios, jų nuomone, yra įžeidžiančios. Ši šakutė ("BraveNewPipe") nebus tokia ribojanti. Jei platformos veikia laisvos saviraiškos dvasia, jas galima integruoti. Vis dėlto pornografiją ar kitus žeminančius dalykus propaguojančios platformos čia nebus integruotos. Ši šakutė bus skirta tik kitų platformų integravimui. Netinkamos pataisos kol kas bus atmestos. Kviečiame siūlyti, kokias alternatyvias platformas reikėtų palaikyti. Bet koks indėlis (vystymas/testas/ataskaita apie klaidą) yra labai laukiamas. Šiuo metu papildomos platformos, palyginti su "NewPipe": + Apie šią šakutę + diff --git a/app/src/brave/res/values-lv/strings.xml b/app/src/brave/res/values-lv/strings.xml new file mode 100644 index 0000000000..408fc57630 --- /dev/null +++ b/app/src/brave/res/values-lv/strings.xml @@ -0,0 +1,5 @@ + + + Ierobežojošās projekta politikas dēļ NewPipeTeam atsakās pievienot platformas, kuras uzskata par aizskarošām. Šī dakša (BraveNewPipe) nebūs tik ierobežojoša. Kamēr platformas darbojas vārda brīvības garā, tās var integrēt. Tomēr platformas, kas popularizē pornogrāfiju vai citas pazemojošas lietas, šeit netiks integrētas. Šī dakša būs vērsta tikai uz citu platformu integrēšanu. Patlaban netiks pieņemti tādi labojumi, kas nav būtiski. Varat brīvi ieteikt, kuras alternatīvas platformas būtu jāatbalsta. Jebkurš ieguldījums (attīstība / testi / kļūdu ziņojums) ir ļoti gaidīts. Pašlaik papildu platformas salīdzinājumā ar NewPipe: + Par šo dakšiņu + diff --git a/app/src/brave/res/values-nl/strings.xml b/app/src/brave/res/values-nl/strings.xml new file mode 100644 index 0000000000..263105955f --- /dev/null +++ b/app/src/brave/res/values-nl/strings.xml @@ -0,0 +1,5 @@ + + + Vanwege een restrictief projectbeleid weigert het NewPipeTeam platforms toe te voegen die zij aanstootgevend vinden. Deze vork (BraveNewPipe) zal niet zo beperkend zijn. Zolang de platforms werken in de geest van vrije meningsuiting, kunnen zij worden geïntegreerd. Platforms die pornografie of andere vernederende zaken promoten zullen hier echter NIET worden geïntegreerd. Deze splitsing zal zich alleen richten op de integratie van andere platforms. Patches die niet relevant zijn, worden voorlopig afgewezen. Voel je vrij om te suggereren welke alternatieve platforms moeten worden ondersteund. Elke bijdrage (ontwikkeling/test/bug report) is zeer welkom. Momenteel extra platforms in vergelijking met NewPipe: + Over deze vork + diff --git a/app/src/brave/res/values-pl/strings.xml b/app/src/brave/res/values-pl/strings.xml new file mode 100644 index 0000000000..e19c74f5dd --- /dev/null +++ b/app/src/brave/res/values-pl/strings.xml @@ -0,0 +1,5 @@ + + + Ze względu na restrykcyjną politykę projektu, NewPipeTeam odmawia dodawania platform, które uważają za obraźliwe. Ten widelec (BraveNewPipe) nie będzie tak restrykcyjny. Tak długo, jak platformy działają w duchu wolnej ekspresji, mogą być zintegrowane. Niemniej jednak platformy, które promują pornografię lub inne poniżające rzeczy nie będą tutaj integrowane. Ten fork będzie się skupiał tylko na integracji innych platform. Łatki, które nie są istotne, będą na razie odrzucane. Zachęcamy do sugerowania, które alternatywne platformy powinny być wspierane. Każdy wkład (rozwój/test/raport o błędach) jest bardzo mile widziany. Obecnie dodatkowe platformy w porównaniu do NewPipe: + O tym widelcu + diff --git a/app/src/brave/res/values-pt/strings.xml b/app/src/brave/res/values-pt/strings.xml new file mode 100644 index 0000000000..82f551c05e --- /dev/null +++ b/app/src/brave/res/values-pt/strings.xml @@ -0,0 +1,5 @@ + + + Devido às políticas restritivas do projecto, a NewPipeTeam recusa-se a acrescentar plataformas que consideram ofensivas. Este garfo (BraveNewPipe) não será tão restritivo. Desde que as plataformas funcionem no espírito da livre expressão, elas podem ser integradas. No entanto, plataformas que promovem a pornografia ou outras coisas degradantes NÃO serão integradas aqui. Este garfo apenas se concentrará na integração de outras plataformas. Os remendos que não sejam relevantes serão rejeitados por agora. Sinta-se à vontade para sugerir que plataformas alternativas devem ser apoiadas. Qualquer contribuição (relatório de desenvolvimento/teste/bug) é muito bem-vinda. Actualmente plataformas adicionais em comparação com NewPipe: + Sobre este garfo + diff --git a/app/src/brave/res/values-ro/strings.xml b/app/src/brave/res/values-ro/strings.xml new file mode 100644 index 0000000000..3485dd88b2 --- /dev/null +++ b/app/src/brave/res/values-ro/strings.xml @@ -0,0 +1,5 @@ + + + Din cauza politicilor restrictive ale proiectului, NewPipeTeam refuză să adauge platforme pe care le consideră ofensatoare. Această bifurcație (BraveNewPipe) nu va fi atât de restrictivă. Atâta timp cât platformele funcționează în spiritul libertății de exprimare, ele pot fi integrate. Cu toate acestea, platformele care promovează pornografia sau alte lucruri degradante nu vor fi NU integrate aici. Această bifurcație se va concentra doar pe integrarea altor platforme. Patch-urile care nu sunt relevante vor fi respinse deocamdată. Nu ezitați să sugerați ce platforme alternative ar trebui să fie acceptate. Orice contribuție (dezvoltare/test/raport de erori) este binevenită. În prezent, platforme suplimentare în comparație cu NewPipe: + Despre această furcă + diff --git a/app/src/brave/res/values-ru/strings.xml b/app/src/brave/res/values-ru/strings.xml new file mode 100644 index 0000000000..b3da12322d --- /dev/null +++ b/app/src/brave/res/values-ru/strings.xml @@ -0,0 +1,5 @@ + + + Из-за ограничительной политики проекта команда NewPipeTeam отказывается добавлять платформы, которые они считают оскорбительными. Эта развилка (BraveNewPipe) не будет столь ограничительной. До тех пор, пока платформы работают в духе свободного выражения мнений, они могут быть интегрированы. Тем не менее, платформы, которые продвигают порнографию или другие унизительные вещи, будут НЕ интегрированы сюда. Этот форк будет сосредоточен только на интеграции других платформ. Патчи, не имеющие отношения к делу, будут пока отклонены. Не стесняйтесь предлагать, какие альтернативные платформы следует поддерживать. Любой вклад (разработка/тест/отчет об ошибках) очень приветствуется. В настоящее время дополнительные платформы по сравнению с NewPipe: + Об этой вилке + diff --git a/app/src/brave/res/values-sk/strings.xml b/app/src/brave/res/values-sk/strings.xml new file mode 100644 index 0000000000..3ac0ba7eb2 --- /dev/null +++ b/app/src/brave/res/values-sk/strings.xml @@ -0,0 +1,5 @@ + + + Vzhľadom na reštriktívne zásady projektu NewPipeTeam odmieta pridávať platformy, ktoré považuje za urážlivé. Táto vidlica (BraveNewPipe) nebude taká obmedzujúca. Pokiaľ platformy fungujú v duchu slobodného vyjadrovania, môžu byť integrované. Platformy, ktoré propagujú pornografiu alebo iné ponižujúce veci, tu však nebudú integrované. Tento fork sa zameriava len na integráciu iných platforiem. Záplaty, ktoré nie sú relevantné, budú zatiaľ zamietnuté. Neváhajte navrhnúť, ktoré alternatívne platformy by mali byť podporované. Akýkoľvek príspevok (vývoj/testy/hlásenie chýb) je veľmi vítaný. V súčasnosti ďalšie platformy v porovnaní s NewPipe: + O tejto vidlici + diff --git a/app/src/brave/res/values-sl/strings.xml b/app/src/brave/res/values-sl/strings.xml new file mode 100644 index 0000000000..fd8a96e998 --- /dev/null +++ b/app/src/brave/res/values-sl/strings.xml @@ -0,0 +1,5 @@ + + + Ekipa NewPipeTeam zaradi omejevalnih pravil projekta ne želi dodajati platform, ki se jim zdijo žaljive. Ta vilica (BraveNewPipe) ne bo tako omejujoča. Dokler platforme delujejo v duhu svobode izražanja, jih je mogoče povezati. Kljub temu platforme, ki spodbujajo pornografijo ali druge ponižujoče stvari, ne bodo vključene. Ta vilica se bo osredotočila le na integracijo drugih platform. Popravki, ki niso pomembni, bodo za zdaj zavrnjeni. Predlagajte, katere alternativne platforme bi bilo treba podpreti. Vsak prispevek (razvoj/test/poročilo o napaki) je zelo dobrodošel. Trenutno dodatne platforme v primerjavi z NewPipe: + O tej vilici + diff --git a/app/src/brave/res/values-sv/strings.xml b/app/src/brave/res/values-sv/strings.xml new file mode 100644 index 0000000000..89c30372ef --- /dev/null +++ b/app/src/brave/res/values-sv/strings.xml @@ -0,0 +1,5 @@ + + + På grund av restriktiva projektpolicyer vägrar NewPipeTeam att lägga till plattformar som de anser vara stötande. Denna gaffel (BraveNewPipe) kommer inte att vara lika restriktiv. Så länge plattformarna fungerar i yttrandefrihetens anda kan de integreras. Plattformar som främjar pornografi eller andra förnedrande saker kommer dock Inte att integreras här. Den här gaffeln kommer endast att fokusera på att integrera andra plattformar. Patchar som inte är relevanta kommer att avvisas tills vidare. Du får gärna föreslå vilka alternativa plattformar som bör stödjas. Alla bidrag (utveckling/test/bug-rapport) är mycket välkomna. För närvarande finns ytterligare plattformar jämfört med NewPipe: + Om denna gaffel + diff --git a/app/src/brave/res/values-zh/strings.xml b/app/src/brave/res/values-zh/strings.xml new file mode 100644 index 0000000000..6723b00983 --- /dev/null +++ b/app/src/brave/res/values-zh/strings.xml @@ -0,0 +1,5 @@ + + + 由于项目政策的限制,NewPipeTeam拒绝添加他们认为不受欢迎的平台。这个分叉(BraveNewPipe)将不会有如此大的限制。只要这些平台本着自由表达的精神工作,它们就可以被整合。尽管如此,宣传色情或其他有辱人格事物的平台将被整合到这里。这个分叉将只关注整合其他平台。不相关的补丁将被暂时拒绝。请随时建议应该支持哪些替代平台。非常欢迎任何贡献(开发/测试/bug报告)。目前与NewPipe相比,额外的平台: + 关于这个叉子 + diff --git a/app/src/brave/res/values/settings_keys.xml b/app/src/brave/res/values/settings_keys.xml new file mode 100644 index 0000000000..055b405cf5 --- /dev/null +++ b/app/src/brave/res/values/settings_keys.xml @@ -0,0 +1,14 @@ + + + + yt3.ggpht.com -> yt4.ggpht.com + yt3.ggpht.com:yt4.ggpht.com + + @string/brave_settings_host_replace_entry_youtube_image_host + + + @string/brave_settings_host_replace_value_youtube_image_host + + + + diff --git a/app/src/brave/res/values/strings.xml b/app/src/brave/res/values/strings.xml new file mode 100644 index 0000000000..9ce0567ace --- /dev/null +++ b/app/src/brave/res/values/strings.xml @@ -0,0 +1,105 @@ + + + Due to restrictive project policy, the NewPipeTeam refuses to add platforms that they find offensive. This fork (BraveNewPipe) will not be as restrictive. As long as the platforms work in the spirit of free speech, they could be integrated. Nevertheless, platforms that promote pornography or other degrading things will NOT be included here. This fork will focus only on integrating other platforms. Unrelated patches will be rejected for now. Feel free to suggest which alternative platforms should be included. Any contribution (development/testing/bug report) is greatly appreciated. Currently additional platforms compared to NewPipe: + About this fork + https://github.com/bravenewpipe + \n\u2022 BitChute\n\u2022 Rumble + + + Short + Long + Rumbles + Most recent + Short (0-5m) + Medium (5-20m) + Long (20m+) + Feature (45m+) + Newest First + Oldest First + + + + 10–30 min + 2–10 min + 360° + 3D + 4–20 min + 4K + Added + artists & labels + Any time + Ascending + Creative Commons + Creation date + Date + Duration + Features + > 30 min + HD + HDR + Kind + Last 30 days + Last 7 days + Last hour + last year + Length + < 2 min + License + Location + Long (> 10 min) + Medium (4–10 min) + No + Over 20 min + Past day + Past hour + Past month + Past week + Past year + Publish date + Published + Purchased + Rating + Relevance + Sensitive + SepiaSearch + Short (< 4 min) + Sort by + Sort order + Subtitles + To modify commercially + Today + Under 4 min + Upload Date + Views + VOD videos + VR180 + This month + This week + This year + Yes + YouTube Music + + + + Filter + Sort filters + Content filters + Select Search Filter UI + Simple Dialog (default) + Action Menu styled Dialog + Action Menu (legacy) + Chip Dialog + + + + BraveNewPipe Settings + + + brave_settings_host_replace_key + Select the hosts you want to replace + Replace restricted hosts + In some countries some hosts are restricted but can be replaced by others. + + + + diff --git a/app/src/brave/res/values/styles_services.xml b/app/src/brave/res/values/styles_services.xml new file mode 100644 index 0000000000..6e83fa3e1e --- /dev/null +++ b/app/src/brave/res/values/styles_services.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/brave/res/xml/brave_settings.xml b/app/src/brave/res/xml/brave_settings.xml new file mode 100644 index 0000000000..5bfb4df223 --- /dev/null +++ b/app/src/brave/res/xml/brave_settings.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/app/src/braveConscrypt/java/org/schabi/newpipe/BraveApp.java b/app/src/braveConscrypt/java/org/schabi/newpipe/BraveApp.java new file mode 100644 index 0000000000..2cb2dd57c3 --- /dev/null +++ b/app/src/braveConscrypt/java/org/schabi/newpipe/BraveApp.java @@ -0,0 +1,15 @@ +package org.schabi.newpipe; + +import android.app.Application; + +import org.conscrypt.Conscrypt; + +import java.security.Security; + +public class BraveApp extends Application { + @Override + public void onCreate() { + super.onCreate(); + Security.insertProviderAt(Conscrypt.newProvider(), 1); + } +} diff --git a/app/src/braveConscrypt/java/org/schabi/newpipe/settings/BraveVideoAudioSettingsBaseFragment.java b/app/src/braveConscrypt/java/org/schabi/newpipe/settings/BraveVideoAudioSettingsBaseFragment.java new file mode 100644 index 0000000000..abde4ec72b --- /dev/null +++ b/app/src/braveConscrypt/java/org/schabi/newpipe/settings/BraveVideoAudioSettingsBaseFragment.java @@ -0,0 +1,4 @@ +package org.schabi.newpipe.settings; + +public abstract class BraveVideoAudioSettingsBaseFragment extends BraveBasePreferenceFragment { +} diff --git a/app/src/braveLegacy/java/org/schabi/newpipe/BraveApp.java b/app/src/braveLegacy/java/org/schabi/newpipe/BraveApp.java new file mode 100644 index 0000000000..79c145bb9d --- /dev/null +++ b/app/src/braveLegacy/java/org/schabi/newpipe/BraveApp.java @@ -0,0 +1,51 @@ +package org.schabi.newpipe; + +import android.content.Context; +import android.os.Build; + +import org.conscrypt.Conscrypt; +import org.schabi.newpipe.settings.BraveVideoAudioSettingsBaseFragment; +import org.schabi.newpipe.util.BraveTLSSocketFactory; + +import java.security.Security; + +import androidx.multidex.MultiDexApplication; + +public class BraveApp extends MultiDexApplication { + private static Context appContext; + + @Override + public void onCreate() { + super.onCreate(); + Security.insertProviderAt(Conscrypt.newProvider(), 1); + appContext = getApplicationContext(); + + // enable TLS1.2/1.3 for <=kitkat devices, to fix download and play for + // media.ccc.de, rumble, soundcloud, peertube sources + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.KITKAT) { + BraveTLSSocketFactory.setAsDefault(); + } + + makeConfigOptionsSuitableForFlavor(); + } + + /** + * Get the application context. + *

+ * In the {@link App} from main source set there is the static method {@link App#getApp()} + * from which many parts of the application get ApplicationContext. But as the class + * {@link App} inherits from BraveApp and sets the app variable in {@link @App#onCreate()} + * only after the {@link BraveApp#onCreate()} is called. Therefore {@link App#getApp()} + * has not yet an initialized return valule thus we need another way to get the + * ApplicationContext + * + * @return the application context + */ + public static Context getAppContext() { + return appContext; + } + + private void makeConfigOptionsSuitableForFlavor() { + BraveVideoAudioSettingsBaseFragment.makeConfigOptionsSuitableForFlavor(getAppContext()); + } +} diff --git a/app/src/braveLegacy/java/org/schabi/newpipe/DownloaderImpl.java b/app/src/braveLegacy/java/org/schabi/newpipe/DownloaderImpl.java new file mode 100644 index 0000000000..32da4f51b9 --- /dev/null +++ b/app/src/braveLegacy/java/org/schabi/newpipe/DownloaderImpl.java @@ -0,0 +1,214 @@ +package org.schabi.newpipe; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.preference.PreferenceManager; + +import org.schabi.newpipe.error.ReCaptchaActivity; +import org.schabi.newpipe.extractor.downloader.Downloader; +import org.schabi.newpipe.extractor.downloader.Request; +import org.schabi.newpipe.extractor.downloader.Response; +import org.schabi.newpipe.extractor.exceptions.ReCaptchaException; +import org.schabi.newpipe.util.InfoCache; +import org.schabi.newpipe.util.BraveOkHttpTlsHelper; + +import java.io.IOException; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import okhttp3.OkHttpClient; +import okhttp3.RequestBody; +import okhttp3.ResponseBody; + +public final class DownloaderImpl extends Downloader { + public static final String USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0"; + public static final String YOUTUBE_RESTRICTED_MODE_COOKIE_KEY = + "youtube_restricted_mode_key"; + public static final String YOUTUBE_RESTRICTED_MODE_COOKIE = "PREF=f2=8000000"; + public static final String YOUTUBE_DOMAIN = "youtube.com"; + + private static final DownloaderImpl INSTANCE = new DownloaderImpl(); + private final Map mCookies; + private OkHttpClient client = new OkHttpClient(); + + private DownloaderImpl() { + this.mCookies = new HashMap<>(); + } + + private void initInternal(final @Nullable OkHttpClient.Builder builder) { + final OkHttpClient.Builder theBuilder = + builder != null ? builder : client.newBuilder(); + theBuilder.readTimeout(30, TimeUnit.SECONDS); +// .cache(new Cache(new File(context.getExternalCacheDir(), "okhttp"), +// 16 * 1024 * 1024)) + BraveDownloaderImplUtils.addOrRemoveInterceptors(theBuilder); + BraveDownloaderImplUtils.addCookieManager(theBuilder); + BraveOkHttpTlsHelper.enableModernTLS(theBuilder); + this.client = theBuilder.build(); + } + + public void reInitInterceptors() { + final OkHttpClient.Builder builder = client.newBuilder(); + BraveDownloaderImplUtils.addOrRemoveInterceptors(builder); + this.client = builder.build(); + } + + /** + * It's recommended to call exactly once in the entire lifetime of the application. + * + * @param builder if null, default builder will be used. If supplying a builder always use + * {@link #getNewBuilder()} to retrieve one - unless you know what you are doing. + * @return a new instance of {@link DownloaderImpl} + */ + public DownloaderImpl init(@Nullable final OkHttpClient.Builder builder) { + initInternal(builder); + return INSTANCE; + } + + public static DownloaderImpl getInstance() { + return INSTANCE; + } + + public OkHttpClient.Builder getNewBuilder() { + return client.newBuilder(); + } + + public String getCookies(final String url) { + final String youtubeCookie = url.contains(YOUTUBE_DOMAIN) + ? getCookie(YOUTUBE_RESTRICTED_MODE_COOKIE_KEY) : null; + + // Recaptcha cookie is always added TODO: not sure if this is necessary + return Stream.of(youtubeCookie, getCookie(ReCaptchaActivity.RECAPTCHA_COOKIES_KEY)) + .filter(Objects::nonNull) + .flatMap(cookies -> Arrays.stream(cookies.split("; *"))) + .distinct() + .collect(Collectors.joining("; ")); + } + + public String getCookie(final String key) { + return mCookies.get(key); + } + + public void setCookie(final String key, final String cookie) { + mCookies.put(key, cookie); + } + + public void removeCookie(final String key) { + mCookies.remove(key); + } + + public void updateYoutubeRestrictedModeCookies(final Context context) { + final String restrictedModeEnabledKey = + context.getString(R.string.youtube_restricted_mode_enabled); + final boolean restrictedModeEnabled = PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean(restrictedModeEnabledKey, false); + updateYoutubeRestrictedModeCookies(restrictedModeEnabled); + } + + public void updateYoutubeRestrictedModeCookies(final boolean youtubeRestrictedModeEnabled) { + if (youtubeRestrictedModeEnabled) { + setCookie(YOUTUBE_RESTRICTED_MODE_COOKIE_KEY, + YOUTUBE_RESTRICTED_MODE_COOKIE); + } else { + removeCookie(YOUTUBE_RESTRICTED_MODE_COOKIE_KEY); + } + InfoCache.getInstance().clearCache(); + } + + /** + * Get the size of the content that the url is pointing by firing a HEAD request. + * + * @param url an url pointing to the content + * @return the size of the content, in bytes + */ + public long getContentLength(final String url) throws IOException { + try { + final Response response = head(url); + if (response.responseCode() == 405) { // HEAD Method not allowed + return BraveDownloaderImplUtils.getContentLengthViaGet(url); + } else { + return Long.parseLong(response.getHeader("Content-Length")); + } + } catch (final NumberFormatException e) { + throw new IOException("Invalid content length", e); + } catch (final ReCaptchaException e) { + throw new IOException(e); + } + } + + @Override + public Response execute(@NonNull final Request request) + throws IOException, ReCaptchaException { + final String httpMethod = request.httpMethod(); + final String url = request.url(); + final Map> headers = request.headers(); + final byte[] dataToSend = request.dataToSend(); + + RequestBody requestBody = null; + if (dataToSend != null) { + requestBody = RequestBody.create(null, dataToSend); + } + + final okhttp3.Request.Builder requestBuilder = new okhttp3.Request.Builder() + .method(httpMethod, requestBody).url(url) + .addHeader("User-Agent", USER_AGENT); + + final String cookies = getCookies(url); + if (!cookies.isEmpty()) { + requestBuilder.addHeader("Cookie", cookies); + } + + for (final Map.Entry> pair : headers.entrySet()) { + final String headerName = pair.getKey(); + final List headerValueList = pair.getValue(); + + if (headerValueList.size() > 1) { + requestBuilder.removeHeader(headerName); + for (final String headerValue : headerValueList) { + requestBuilder.addHeader(headerName, headerValue); + } + } else if (headerValueList.size() == 1) { + requestBuilder.header(headerName, headerValueList.get(0)); + } + + } + + final okhttp3.Response response = client.newCall(requestBuilder.build()).execute(); + + if (response.code() == 429) { + response.close(); + + throw new ReCaptchaException("reCaptcha Challenge requested", url); + } + + final ResponseBody body = response.body(); + String responseBodyToReturn = null; + + if (body != null) { + responseBodyToReturn = body.string(); + } + + final String latestUrl = response.request().url().toString(); + final Response downloaderResponse = new Response( + response.code(), + response.message(), + response.headers().toMultimap(), + responseBodyToReturn, + latestUrl + ); + + // always close the OkHttp Response + response.close(); + + return downloaderResponse; + } +} diff --git a/app/src/braveLegacy/java/org/schabi/newpipe/ExitActivity.java b/app/src/braveLegacy/java/org/schabi/newpipe/ExitActivity.java new file mode 100644 index 0000000000..8da22db2d3 --- /dev/null +++ b/app/src/braveLegacy/java/org/schabi/newpipe/ExitActivity.java @@ -0,0 +1,55 @@ +package org.schabi.newpipe; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.Intent; +import android.os.Build; +import android.os.Bundle; + +import org.schabi.newpipe.util.NavigationHelper; + +/* + * Copyright (C) Hans-Christoph Steiner 2016 + * ExitActivity.java is part of NewPipe. + * + * NewPipe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * NewPipe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with NewPipe. If not, see . + */ + +public class ExitActivity extends Activity { + + public static void exitAndRemoveFromRecentApps(final Activity activity) { + final Intent intent = new Intent(activity, ExitActivity.class); + + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK + | Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS + | Intent.FLAG_ACTIVITY_CLEAR_TASK + | Intent.FLAG_ACTIVITY_NO_ANIMATION); + + activity.startActivity(intent); + } + + @SuppressLint("NewApi") + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + finishAndRemoveTask(); + } else { + finish(); + } + + NavigationHelper.restartApp(this); + } +} diff --git a/app/src/braveLegacy/java/org/schabi/newpipe/PanicResponderActivity.java b/app/src/braveLegacy/java/org/schabi/newpipe/PanicResponderActivity.java new file mode 100644 index 0000000000..b4fbdfb286 --- /dev/null +++ b/app/src/braveLegacy/java/org/schabi/newpipe/PanicResponderActivity.java @@ -0,0 +1,49 @@ +package org.schabi.newpipe; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.Intent; +import android.os.Build; +import android.os.Bundle; + +/* + * Copyright (C) Hans-Christoph Steiner 2016 + * PanicResponderActivity.java is part of NewPipe. + * + * NewPipe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * NewPipe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with NewPipe. If not, see . + */ + +public class PanicResponderActivity extends Activity { + public static final String PANIC_TRIGGER_ACTION = "info.guardianproject.panic.action.TRIGGER"; + + @SuppressLint("NewApi") + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + final Intent intent = getIntent(); + if (intent != null && PANIC_TRIGGER_ACTION.equals(intent.getAction())) { + // TODO: Explicitly clear the search results + // once they are restored when the app restarts + // or if the app reloads the current video after being killed, + // that should be cleared also + ExitActivity.exitAndRemoveFromRecentApps(this); + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + finishAndRemoveTask(); + } else { + finish(); + } + } +} diff --git a/app/src/braveLegacy/java/org/schabi/newpipe/error/ErrorUtil.kt b/app/src/braveLegacy/java/org/schabi/newpipe/error/ErrorUtil.kt new file mode 100644 index 0000000000..375f6ba6e9 --- /dev/null +++ b/app/src/braveLegacy/java/org/schabi/newpipe/error/ErrorUtil.kt @@ -0,0 +1,186 @@ +package org.schabi.newpipe.error + +import android.app.Activity +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.os.Build +import android.view.View +import android.widget.Toast +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.PendingIntentCompat +import androidx.fragment.app.Fragment +import androidx.preference.PreferenceManager +import com.google.android.material.snackbar.Snackbar +import org.schabi.newpipe.R + +/** + * This class contains all of the methods that should be used to let the user know that an error has + * occurred in the least intrusive way possible for each case. This class is for unexpected errors, + * for handled errors (e.g. network errors) use e.g. [ErrorPanelHelper] instead. + * - Use a snackbar if the exception is not critical and it happens in a place where a root view + * is available. + * - Use a notification if the exception happens inside a background service (player, subscription + * import, ...) or there is no activity/fragment from which to extract a root view. + * - Finally use the error activity only as a last resort in case the exception is critical and + * happens in an open activity (since the workflow would be interrupted anyway in that case). + */ +class ErrorUtil { + companion object { + private const val ERROR_REPORT_NOTIFICATION_ID = 5340681 + + /** + * Starts a new error activity allowing the user to report the provided error. Only use this + * method directly as a last resort in case the exception is critical and happens in an open + * activity (since the workflow would be interrupted anyway in that case). So never use this + * for background services. + * + * @param context the context to use to start the new activity + * @param errorInfo the error info to be reported + */ + @JvmStatic + fun openActivity(context: Context, errorInfo: ErrorInfo) { + if (getIsErrorReportsDisabled(context)) { + return + } + + context.startActivity(getErrorActivityIntent(context, errorInfo)) + } + + /** + * Show a bottom snackbar to the user, with a report button that opens the error activity. + * Use this method if the exception is not critical and it happens in a place where a root + * view is available. + * + * @param context will be used to obtain the root view if it is an [Activity]; if no root + * view can be found an error notification is shown instead + * @param errorInfo the error info to be reported + */ + @JvmStatic + fun showSnackbar(context: Context, errorInfo: ErrorInfo) { + val rootView = if (context is Activity) context.findViewById(R.id.content) else null + showSnackbar(context, rootView, errorInfo) + } + + /** + * Show a bottom snackbar to the user, with a report button that opens the error activity. + * Use this method if the exception is not critical and it happens in a place where a root + * view is available. + * + * @param fragment will be used to obtain the root view if it has a connected [Activity]; if + * no root view can be found an error notification is shown instead + * @param errorInfo the error info to be reported + */ + @JvmStatic + fun showSnackbar(fragment: Fragment, errorInfo: ErrorInfo) { + var rootView = fragment.view + if (rootView == null && fragment.activity != null) { + rootView = fragment.requireActivity().findViewById(R.id.content) + } + showSnackbar(fragment.requireContext(), rootView, errorInfo) + } + + /** + * Shortcut to calling [showSnackbar] with an [ErrorInfo] of type [UserAction.UI_ERROR] + */ + @JvmStatic + fun showUiErrorSnackbar(context: Context, request: String, throwable: Throwable) { + showSnackbar(context, ErrorInfo(throwable, UserAction.UI_ERROR, request)) + } + + /** + * Shortcut to calling [showSnackbar] with an [ErrorInfo] of type [UserAction.UI_ERROR] + */ + @JvmStatic + fun showUiErrorSnackbar(fragment: Fragment, request: String, throwable: Throwable) { + showSnackbar(fragment, ErrorInfo(throwable, UserAction.UI_ERROR, request)) + } + + /** + * Create an error notification. Tapping on the notification opens the error activity. Use + * this method if the exception happens inside a background service (player, subscription + * import, ...) or there is no activity/fragment from which to extract a root view. + * + * @param context the context to use to show the notification + * @param errorInfo the error info to be reported; the error message + * [ErrorInfo.messageStringId] will be shown in the notification + * description + */ + @JvmStatic + fun createNotification(context: Context, errorInfo: ErrorInfo) { + if (getIsErrorReportsDisabled(context)) { + return + } + + var pendingIntentFlags = PendingIntent.FLAG_UPDATE_CURRENT + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + pendingIntentFlags = pendingIntentFlags or PendingIntent.FLAG_IMMUTABLE + } + + val notificationBuilder: NotificationCompat.Builder = + NotificationCompat.Builder( + context, + context.getString(R.string.error_report_channel_id) + ) + .setSmallIcon( + // the vector drawable icon causes crashes on KitKat devices + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) + R.drawable.ic_bug_report + else + android.R.drawable.stat_notify_error + ) + .setContentTitle(context.getString(R.string.error_report_notification_title)) + .setContentText(context.getString(errorInfo.messageStringId)) + .setAutoCancel(true) + .setContentIntent( + PendingIntentCompat.getActivity( + context, + 0, + getErrorActivityIntent(context, errorInfo), + PendingIntent.FLAG_UPDATE_CURRENT, + false + ) + ) + + NotificationManagerCompat.from(context) + .notify(ERROR_REPORT_NOTIFICATION_ID, notificationBuilder.build()) + + // since the notification is silent, also show a toast, otherwise the user is confused + Toast.makeText(context, R.string.error_report_notification_toast, Toast.LENGTH_SHORT) + .show() + } + + private fun getErrorActivityIntent(context: Context, errorInfo: ErrorInfo): Intent { + val intent = Intent(context, ErrorActivity::class.java) + intent.putExtra(ErrorActivity.ERROR_INFO, errorInfo) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + return intent + } + + private fun showSnackbar(context: Context, rootView: View?, errorInfo: ErrorInfo) { + if (getIsErrorReportsDisabled(context)) { + return + } + + if (rootView == null) { + // fallback to showing a notification if no root view is available + createNotification(context, errorInfo) + } else { + Snackbar.make(rootView, R.string.error_snackbar_message, Snackbar.LENGTH_LONG) + .setActionTextColor(Color.YELLOW) + .setAction(context.getString(R.string.error_snackbar_action).uppercase()) { + openActivity(context, errorInfo) + }.show() + } + } + + private fun getIsErrorReportsDisabled(context: Context): Boolean { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + return prefs.getBoolean( + context.getString(R.string.disable_error_reports_key), false + ) + } + } +} diff --git a/app/src/braveLegacy/java/org/schabi/newpipe/error/ReCaptchaActivity.java b/app/src/braveLegacy/java/org/schabi/newpipe/error/ReCaptchaActivity.java new file mode 100644 index 0000000000..ac1f34e3d1 --- /dev/null +++ b/app/src/braveLegacy/java/org/schabi/newpipe/error/ReCaptchaActivity.java @@ -0,0 +1,244 @@ +package org.schabi.newpipe.error; + +import android.annotation.SuppressLint; +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Build; +import android.os.Bundle; +import android.util.Log; +import android.view.Menu; +import android.view.MenuItem; +import android.webkit.CookieManager; +import android.webkit.WebResourceRequest; +import android.webkit.WebSettings; +import android.webkit.WebView; +import android.webkit.WebViewClient; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.app.NavUtils; +import androidx.preference.PreferenceManager; + +import org.schabi.newpipe.DownloaderImpl; +import org.schabi.newpipe.MainActivity; +import org.schabi.newpipe.R; +import org.schabi.newpipe.databinding.ActivityRecaptchaBinding; +import org.schabi.newpipe.extractor.utils.Utils; +import org.schabi.newpipe.util.ThemeHelper; + +import java.io.UnsupportedEncodingException; + +/* + * Created by beneth on 06.12.16. + * + * Copyright (C) Christian Schabesberger 2015 + * ReCaptchaActivity.java is part of NewPipe. + * + * NewPipe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * NewPipe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with NewPipe. If not, see . + */ +public class ReCaptchaActivity extends AppCompatActivity { + public static final int RECAPTCHA_REQUEST = 10; + public static final String RECAPTCHA_URL_EXTRA = "recaptcha_url_extra"; + public static final String TAG = ReCaptchaActivity.class.toString(); + public static final String YT_URL = "https://www.youtube.com"; + public static final String RECAPTCHA_COOKIES_KEY = "recaptcha_cookies"; + + public static String sanitizeRecaptchaUrl(@Nullable final String url) { + if (url == null || url.trim().isEmpty()) { + return YT_URL; // YouTube is the most likely service to have thrown a recaptcha + } else { + // remove "pbj=1" parameter from YouYube urls, as it makes the page JSON and not HTML + return url.replace("&pbj=1", "").replace("pbj=1&", "").replace("?pbj=1", ""); + } + } + + private ActivityRecaptchaBinding recaptchaBinding; + private String foundCookies = ""; + + @SuppressLint("SetJavaScriptEnabled") + @Override + protected void onCreate(final Bundle savedInstanceState) { + ThemeHelper.setTheme(this); + super.onCreate(savedInstanceState); + + recaptchaBinding = ActivityRecaptchaBinding.inflate(getLayoutInflater()); + setContentView(recaptchaBinding.getRoot()); + setSupportActionBar(recaptchaBinding.toolbar); + + final String url = sanitizeRecaptchaUrl(getIntent().getStringExtra(RECAPTCHA_URL_EXTRA)); + // set return to Cancel by default + setResult(RESULT_CANCELED); + + // enable Javascript + final WebSettings webSettings = recaptchaBinding.reCaptchaWebView.getSettings(); + webSettings.setJavaScriptEnabled(true); + webSettings.setUserAgentString(DownloaderImpl.USER_AGENT); + + recaptchaBinding.reCaptchaWebView.setWebViewClient(new WebViewClient() { + @Override + public boolean shouldOverrideUrlLoading(final WebView view, + final WebResourceRequest request) { + if (MainActivity.DEBUG) { + Log.d(TAG, "shouldOverrideUrlLoading: url=" + request.getUrl().toString()); + } + + handleCookiesFromUrl(request.getUrl().toString()); + return false; + } + + @Override + public void onPageFinished(final WebView view, final String url) { + super.onPageFinished(view, url); + handleCookiesFromUrl(url); + } + }); + + // cleaning cache, history and cookies from webView + recaptchaBinding.reCaptchaWebView.clearCache(true); + recaptchaBinding.reCaptchaWebView.clearHistory(); + final CookieManager cookieManager = CookieManager.getInstance(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + cookieManager.removeAllCookies(value -> { }); + } else { + cookieManager.removeAllCookie(); + } + + recaptchaBinding.reCaptchaWebView.loadUrl(url); + } + + @Override + public boolean onCreateOptionsMenu(final Menu menu) { + getMenuInflater().inflate(R.menu.menu_recaptcha, menu); + + final ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setDisplayHomeAsUpEnabled(false); + actionBar.setTitle(R.string.title_activity_recaptcha); + actionBar.setSubtitle(R.string.subtitle_activity_recaptcha); + } + + return true; + } + + @Override + public void onBackPressed() { + saveCookiesAndFinish(); + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + if (item.getItemId() == R.id.menu_item_done) { + saveCookiesAndFinish(); + return true; + } + return false; + } + + private void saveCookiesAndFinish() { + // try to get cookies of unclosed page + handleCookiesFromUrl(recaptchaBinding.reCaptchaWebView.getUrl()); + if (MainActivity.DEBUG) { + Log.d(TAG, "saveCookiesAndFinish: foundCookies=" + foundCookies); + } + + if (!foundCookies.isEmpty()) { + // save cookies to preferences + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences( + getApplicationContext()); + final String key = getApplicationContext().getString(R.string.recaptcha_cookies_key); + prefs.edit().putString(key, foundCookies).apply(); + + // give cookies to Downloader class + DownloaderImpl.getInstance().setCookie(RECAPTCHA_COOKIES_KEY, foundCookies); + setResult(RESULT_OK); + } + + // Navigate to blank page (unloads youtube to prevent background playback) + recaptchaBinding.reCaptchaWebView.loadUrl("about:blank"); + + final Intent intent = new Intent(this, org.schabi.newpipe.MainActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + NavUtils.navigateUpTo(this, intent); + } + + + private void handleCookiesFromUrl(@Nullable final String url) { + if (MainActivity.DEBUG) { + Log.d(TAG, "handleCookiesFromUrl: url=" + (url == null ? "null" : url)); + } + + if (url == null) { + return; + } + + final String cookies = CookieManager.getInstance().getCookie(url); + handleCookies(cookies); + + // sometimes cookies are inside the url + final int abuseStart = url.indexOf("google_abuse="); + if (abuseStart != -1) { + final int abuseEnd = url.indexOf("+path"); + + try { + String abuseCookie = url.substring(abuseStart + 13, abuseEnd); + abuseCookie = Utils.decodeUrlUtf8(abuseCookie); + handleCookies(abuseCookie); + } catch (UnsupportedEncodingException | StringIndexOutOfBoundsException e) { + if (MainActivity.DEBUG) { + e.printStackTrace(); + Log.d(TAG, "handleCookiesFromUrl: invalid google abuse starting at " + + abuseStart + " and ending at " + abuseEnd + " for url " + url); + } + } + } + } + + private void handleCookies(@Nullable final String cookies) { + if (MainActivity.DEBUG) { + Log.d(TAG, "handleCookies: cookies=" + (cookies == null ? "null" : cookies)); + } + + if (cookies == null) { + return; + } + + addYoutubeCookies(cookies); + // add here methods to extract cookies for other services + } + + private void addYoutubeCookies(@NonNull final String cookies) { + if (cookies.contains("s_gl=") || cookies.contains("goojf=") + || cookies.contains("VISITOR_INFO1_LIVE=") + || cookies.contains("GOOGLE_ABUSE_EXEMPTION=")) { + // youtube seems to also need the other cookies: + addCookie(cookies); + } + } + + private void addCookie(final String cookie) { + if (foundCookies.contains(cookie)) { + return; + } + + if (foundCookies.isEmpty() || foundCookies.endsWith("; ")) { + foundCookies += cookie; + } else if (foundCookies.endsWith(";")) { + foundCookies += " " + cookie; + } else { + foundCookies += "; " + cookie; + } + } +} diff --git a/app/src/braveLegacy/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java b/app/src/braveLegacy/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java new file mode 100644 index 0000000000..e3971f4d55 --- /dev/null +++ b/app/src/braveLegacy/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java @@ -0,0 +1,2499 @@ +package org.schabi.newpipe.fragments.detail; + +import static android.text.TextUtils.isEmpty; +import static org.schabi.newpipe.extractor.StreamingService.ServiceInfo.MediaCapability.COMMENTS; +import static org.schabi.newpipe.extractor.stream.StreamExtractor.NO_AGE_LIMIT; +import static org.schabi.newpipe.ktx.ViewUtils.animate; +import static org.schabi.newpipe.ktx.ViewUtils.animateRotation; +import static org.schabi.newpipe.player.helper.PlayerHelper.globalScreenOrientationLocked; +import static org.schabi.newpipe.player.helper.PlayerHelper.isClearingQueueConfirmationRequired; +import static org.schabi.newpipe.util.DependentPreferenceHelper.getResumePlaybackEnabled; +import static org.schabi.newpipe.util.ExtractorHelper.showMetaInfoInTextView; +import static org.schabi.newpipe.util.ListHelper.getUrlAndNonTorrentStreams; +import static org.schabi.newpipe.util.NavigationHelper.openPlayQueue; + +import android.animation.ValueAnimator; +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.content.pm.ActivityInfo; +import android.database.ContentObserver; +import android.graphics.Color; +import android.graphics.Rect; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.provider.Settings; +import android.util.DisplayMetrics; +import android.util.Log; +import android.util.TypedValue; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; +import android.view.WindowManager; +import android.view.animation.DecelerateInterpolator; +import android.widget.FrameLayout; +import android.widget.RelativeLayout; +import android.widget.Toast; + +import androidx.annotation.AttrRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.content.res.AppCompatResources; +import androidx.appcompat.widget.Toolbar; +import androidx.coordinatorlayout.widget.CoordinatorLayout; +import androidx.core.content.ContextCompat; +import androidx.fragment.app.Fragment; +import androidx.preference.PreferenceManager; + +import com.google.android.exoplayer2.PlaybackException; +import com.google.android.exoplayer2.PlaybackParameters; +import com.google.android.material.appbar.AppBarLayout; +import com.google.android.material.bottomsheet.BottomSheetBehavior; +import com.google.android.material.tabs.TabLayout; + +import org.schabi.newpipe.App; +import org.schabi.newpipe.R; +import org.schabi.newpipe.database.stream.model.StreamEntity; +import org.schabi.newpipe.databinding.FragmentVideoDetailBinding; +import org.schabi.newpipe.download.DownloadDialog; +import org.schabi.newpipe.error.ErrorInfo; +import org.schabi.newpipe.error.ErrorUtil; +import org.schabi.newpipe.error.ReCaptchaActivity; +import org.schabi.newpipe.error.UserAction; +import org.schabi.newpipe.extractor.Image; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.extractor.comments.CommentsInfoItem; +import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException; +import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.extractor.stream.AudioStream; +import org.schabi.newpipe.extractor.stream.Stream; +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.extractor.stream.StreamType; +import org.schabi.newpipe.extractor.stream.VideoStream; +import org.schabi.newpipe.fragments.BackPressable; +import org.schabi.newpipe.fragments.BaseStateFragment; +import org.schabi.newpipe.fragments.EmptyFragment; +import org.schabi.newpipe.fragments.MainFragment; +import org.schabi.newpipe.fragments.list.comments.CommentsFragment; +import org.schabi.newpipe.fragments.list.videos.RelatedItemsFragment; +import org.schabi.newpipe.ktx.AnimationType; +import org.schabi.newpipe.local.dialog.PlaylistDialog; +import org.schabi.newpipe.local.history.HistoryRecordManager; +import org.schabi.newpipe.local.playlist.LocalPlaylistFragment; +import org.schabi.newpipe.player.Player; +import org.schabi.newpipe.player.PlayerService; +import org.schabi.newpipe.player.PlayerType; +import org.schabi.newpipe.player.event.OnKeyDownListener; +import org.schabi.newpipe.player.event.PlayerServiceExtendedEventListener; +import org.schabi.newpipe.player.helper.PlayerHelper; +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.player.playqueue.SinglePlayQueue; +import org.schabi.newpipe.player.ui.MainPlayerUi; +import org.schabi.newpipe.player.ui.VideoPlayerUi; +import org.schabi.newpipe.util.Constants; +import org.schabi.newpipe.util.DeviceUtils; +import org.schabi.newpipe.util.ExtractorHelper; +import org.schabi.newpipe.util.ReturnYouTubeDislikeUtils; +import org.schabi.newpipe.util.external_communication.KoreUtils; +import org.schabi.newpipe.util.InfoCache; +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.PlayButtonHelper; +import org.schabi.newpipe.util.StreamTypeUtil; +import org.schabi.newpipe.util.external_communication.ShareUtils; +import org.schabi.newpipe.util.ThemeHelper; +import org.schabi.newpipe.util.image.PicassoHelper; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +import icepick.State; +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; +import io.reactivex.rxjava3.disposables.CompositeDisposable; +import io.reactivex.rxjava3.disposables.Disposable; +import io.reactivex.rxjava3.schedulers.Schedulers; + +public final class VideoDetailFragment + extends BaseStateFragment + implements BackPressable, + PlayerServiceExtendedEventListener, + OnKeyDownListener { + public static final String KEY_SWITCHING_PLAYERS = "switching_players"; + + private static final float MAX_OVERLAY_ALPHA = 0.9f; + private static final float MAX_PLAYER_HEIGHT = 0.7f; + + public static final String ACTION_SHOW_MAIN_PLAYER = + App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_SHOW_MAIN_PLAYER"; + public static final String ACTION_HIDE_MAIN_PLAYER = + App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_HIDE_MAIN_PLAYER"; + public static final String ACTION_PLAYER_STARTED = + App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_PLAYER_STARTED"; + public static final String ACTION_VIDEO_FRAGMENT_RESUMED = + App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_VIDEO_FRAGMENT_RESUMED"; + public static final String ACTION_VIDEO_FRAGMENT_STOPPED = + App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_VIDEO_FRAGMENT_STOPPED"; + + private static final String COMMENTS_TAB_TAG = "COMMENTS"; + private static final String RELATED_TAB_TAG = "NEXT VIDEO"; + private static final String DESCRIPTION_TAB_TAG = "DESCRIPTION TAB"; + private static final String EMPTY_TAB_TAG = "EMPTY TAB"; + + private static final String PICASSO_VIDEO_DETAILS_TAG = "PICASSO_VIDEO_DETAILS_TAG"; + + // tabs + private boolean showComments; + private boolean showRelatedItems; + private boolean showDescription; + private String selectedTabTag; + @AttrRes + @NonNull + final List tabIcons = new ArrayList<>(); + @StringRes + @NonNull + final List tabContentDescriptions = new ArrayList<>(); + private boolean tabSettingsChanged = false; + private int lastAppBarVerticalOffset = Integer.MAX_VALUE; // prevents useless updates + + private final SharedPreferences.OnSharedPreferenceChangeListener preferenceChangeListener = + (sharedPreferences, key) -> { + if (getString(R.string.show_comments_key).equals(key)) { + showComments = sharedPreferences.getBoolean(key, true); + tabSettingsChanged = true; + } else if (getString(R.string.show_next_video_key).equals(key)) { + showRelatedItems = sharedPreferences.getBoolean(key, true); + tabSettingsChanged = true; + } else if (getString(R.string.show_description_key).equals(key)) { + showDescription = sharedPreferences.getBoolean(key, true); + tabSettingsChanged = true; + } + }; + + @State + protected int serviceId = Constants.NO_SERVICE_ID; + @State + @NonNull + protected String title = ""; + @State + @Nullable + protected String url = null; + @Nullable + protected PlayQueue playQueue = null; + @State + int bottomSheetState = BottomSheetBehavior.STATE_EXPANDED; + @State + int lastStableBottomSheetState = BottomSheetBehavior.STATE_EXPANDED; + @State + protected boolean autoPlayEnabled = true; + + @Nullable + private StreamInfo currentInfo = null; + private Disposable currentWorker; + @NonNull + private final CompositeDisposable disposables = new CompositeDisposable(); + @Nullable + private Disposable positionSubscriber = null; + + private BottomSheetBehavior bottomSheetBehavior; + private BottomSheetBehavior.BottomSheetCallback bottomSheetCallback; + private BroadcastReceiver broadcastReceiver; + + /*////////////////////////////////////////////////////////////////////////// + // Views + //////////////////////////////////////////////////////////////////////////*/ + + private FragmentVideoDetailBinding binding; + + private TabAdapter pageAdapter; + + private ContentObserver settingsContentObserver; + @Nullable + private PlayerService playerService; + private Player player; + private final PlayerHolder playerHolder = PlayerHolder.getInstance(); + + /*////////////////////////////////////////////////////////////////////////// + // Service management + //////////////////////////////////////////////////////////////////////////*/ + @Override + public void onServiceConnected(final Player connectedPlayer, + final PlayerService connectedPlayerService, + final boolean playAfterConnect) { + player = connectedPlayer; + playerService = connectedPlayerService; + + // It will do nothing if the player is not in fullscreen mode + hideSystemUiIfNeeded(); + + final Optional playerUi = player.UIs().get(MainPlayerUi.class); + if (!player.videoPlayerSelected() && !playAfterConnect) { + return; + } + + if (DeviceUtils.isLandscape(requireContext())) { + // If the video is playing but orientation changed + // let's make the video in fullscreen again + checkLandscape(); + } else if (playerUi.map(ui -> ui.isFullscreen() && !ui.isVerticalVideo()).orElse(false) + // Tablet UI has orientation-independent fullscreen + && !DeviceUtils.isTablet(activity)) { + // Device is in portrait orientation after rotation but UI is in fullscreen. + // Return back to non-fullscreen state + playerUi.ifPresent(MainPlayerUi::toggleFullscreen); + } + + if (playAfterConnect + || (currentInfo != null + && isAutoplayEnabled() + && playerUi.isEmpty())) { + autoPlayEnabled = true; // forcefully start playing + openVideoPlayerAutoFullscreen(); + } + updateOverlayPlayQueueButtonVisibility(); + } + + @Override + public void onServiceDisconnected() { + playerService = null; + player = null; + restoreDefaultBrightness(); + } + + + /*////////////////////////////////////////////////////////////////////////*/ + + public static VideoDetailFragment getInstance(final int serviceId, + @Nullable final String videoUrl, + @NonNull final String name, + @Nullable final PlayQueue queue) { + final VideoDetailFragment instance = new VideoDetailFragment(); + instance.setInitialData(serviceId, videoUrl, name, queue); + return instance; + } + + public static VideoDetailFragment getInstanceInCollapsedState() { + final VideoDetailFragment instance = new VideoDetailFragment(); + instance.updateBottomSheetState(BottomSheetBehavior.STATE_COLLAPSED); + return instance; + } + + + /*////////////////////////////////////////////////////////////////////////// + // Fragment's Lifecycle + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(activity); + showComments = prefs.getBoolean(getString(R.string.show_comments_key), true); + showRelatedItems = prefs.getBoolean(getString(R.string.show_next_video_key), true); + showDescription = prefs.getBoolean(getString(R.string.show_description_key), true); + selectedTabTag = prefs.getString( + getString(R.string.stream_info_selected_tab_key), COMMENTS_TAB_TAG); + prefs.registerOnSharedPreferenceChangeListener(preferenceChangeListener); + + setupBroadcastReceiver(); + + settingsContentObserver = new ContentObserver(new Handler()) { + @Override + public void onChange(final boolean selfChange) { + if (activity != null && !globalScreenOrientationLocked(activity)) { + activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED); + } + } + }; + activity.getContentResolver().registerContentObserver( + Settings.System.getUriFor(Settings.System.ACCELEROMETER_ROTATION), false, + settingsContentObserver); + } + + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, + final Bundle savedInstanceState) { + binding = FragmentVideoDetailBinding.inflate(inflater, container, false); + return binding.getRoot(); + } + + @Override + public void onPause() { + super.onPause(); + if (currentWorker != null) { + currentWorker.dispose(); + } + restoreDefaultBrightness(); + PreferenceManager.getDefaultSharedPreferences(requireContext()) + .edit() + .putString(getString(R.string.stream_info_selected_tab_key), + pageAdapter.getItemTitle(binding.viewPager.getCurrentItem())) + .apply(); + } + + @Override + public void onResume() { + super.onResume(); + if (DEBUG) { + Log.d(TAG, "onResume() called"); + } + + activity.sendBroadcast(new Intent(ACTION_VIDEO_FRAGMENT_RESUMED)); + + updateOverlayPlayQueueButtonVisibility(); + + setupBrightness(); + + if (tabSettingsChanged) { + tabSettingsChanged = false; + initTabs(); + if (currentInfo != null) { + updateTabs(currentInfo); + } + } + + // Check if it was loading when the fragment was stopped/paused + if (wasLoading.getAndSet(false) && !wasCleared()) { + startLoading(false); + } + } + + @Override + public void onStop() { + super.onStop(); + + if (!activity.isChangingConfigurations()) { + activity.sendBroadcast(new Intent(ACTION_VIDEO_FRAGMENT_STOPPED)); + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + + // Stop the service when user leaves the app with double back press + // if video player is selected. Otherwise unbind + if (activity.isFinishing() && isPlayerAvailable() && player.videoPlayerSelected()) { + playerHolder.stopService(); + } else { + playerHolder.setListener(null); + } + + PreferenceManager.getDefaultSharedPreferences(activity) + .unregisterOnSharedPreferenceChangeListener(preferenceChangeListener); + activity.unregisterReceiver(broadcastReceiver); + activity.getContentResolver().unregisterContentObserver(settingsContentObserver); + + if (positionSubscriber != null) { + positionSubscriber.dispose(); + } + if (currentWorker != null) { + currentWorker.dispose(); + } + disposables.clear(); + positionSubscriber = null; + currentWorker = null; + bottomSheetBehavior.removeBottomSheetCallback(bottomSheetCallback); + + if (activity.isFinishing()) { + playQueue = null; + currentInfo = null; + stack = new LinkedList<>(); + } + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + binding = null; + } + + @Override + public void onActivityResult(final int requestCode, final int resultCode, final Intent data) { + super.onActivityResult(requestCode, resultCode, data); + switch (requestCode) { + case ReCaptchaActivity.RECAPTCHA_REQUEST: + if (resultCode == Activity.RESULT_OK) { + NavigationHelper.openVideoDetailFragment(requireContext(), getFM(), + serviceId, url, title, null, false); + } else { + Log.e(TAG, "ReCaptcha failed"); + } + break; + default: + Log.e(TAG, "Request code from activity not supported [" + requestCode + "]"); + break; + } + } + + /*////////////////////////////////////////////////////////////////////////// + // OnClick + //////////////////////////////////////////////////////////////////////////*/ + + private void setOnClickListeners() { + binding.detailTitleRootLayout.setOnClickListener(v -> toggleTitleAndSecondaryControls()); + binding.detailUploaderRootLayout.setOnClickListener(makeOnClickListener(info -> { + if (isEmpty(info.getSubChannelUrl())) { + if (!isEmpty(info.getUploaderUrl())) { + openChannel(info.getUploaderUrl(), info.getUploaderName()); + } + + if (DEBUG) { + Log.i(TAG, "Can't open sub-channel because we got no channel URL"); + } + } else { + openChannel(info.getSubChannelUrl(), info.getSubChannelName()); + } + })); + binding.detailThumbnailRootLayout.setOnClickListener(v -> { + autoPlayEnabled = true; // forcefully start playing + // FIXME Workaround #7427 + if (isPlayerAvailable()) { + player.setRecovery(); + } + openVideoPlayerAutoFullscreen(); + }); + + binding.detailControlsBackground.setOnClickListener(v -> openBackgroundPlayer(false)); + binding.detailControlsPopup.setOnClickListener(v -> openPopupPlayer(false)); + binding.detailControlsPlaylistAppend.setOnClickListener(makeOnClickListener(info -> { + if (getFM() != null && currentInfo != null) { + final Fragment fragment = getParentFragmentManager(). + findFragmentById(R.id.fragment_holder); + + // commit previous pending changes to database + if (fragment instanceof LocalPlaylistFragment) { + ((LocalPlaylistFragment) fragment).saveImmediate(); + } else if (fragment instanceof MainFragment) { + ((MainFragment) fragment).commitPlaylistTabs(); + } + + disposables.add(PlaylistDialog.createCorrespondingDialog(requireContext(), + List.of(new StreamEntity(info)), + dialog -> dialog.show(getParentFragmentManager(), TAG))); + } + })); + binding.detailControlsDownload.setOnClickListener(v -> { + if (PermissionHelper.checkStoragePermissions(activity, + PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE)) { + openDownloadDialog(); + } + }); + binding.detailControlsShare.setOnClickListener(makeOnClickListener(info -> + ShareUtils.shareText(requireContext(), info.getName(), info.getUrl(), + info.getThumbnails()))); + binding.detailControlsOpenInBrowser.setOnClickListener(makeOnClickListener(info -> + ShareUtils.openUrlInBrowser(requireContext(), info.getUrl()))); + binding.detailControlsPlayWithKodi.setOnClickListener(makeOnClickListener(info -> + KoreUtils.playWithKore(requireContext(), Uri.parse(info.getUrl())))); + if (DEBUG) { + binding.detailControlsCrashThePlayer.setOnClickListener(v -> + VideoDetailPlayerCrasher.onCrashThePlayer(requireContext(), player)); + } + + final View.OnClickListener overlayListener = v -> bottomSheetBehavior + .setState(BottomSheetBehavior.STATE_EXPANDED); + binding.overlayThumbnail.setOnClickListener(overlayListener); + binding.overlayMetadataLayout.setOnClickListener(overlayListener); + binding.overlayButtonsLayout.setOnClickListener(overlayListener); + binding.overlayCloseButton.setOnClickListener(v -> bottomSheetBehavior + .setState(BottomSheetBehavior.STATE_HIDDEN)); + binding.overlayPlayQueueButton.setOnClickListener(v -> openPlayQueue(requireContext())); + binding.overlayPlayPauseButton.setOnClickListener(v -> { + if (playerIsNotStopped()) { + player.playPause(); + player.UIs().get(VideoPlayerUi.class).ifPresent(ui -> ui.hideControls(0, 0)); + showSystemUi(); + } else { + autoPlayEnabled = true; // forcefully start playing + openVideoPlayer(false); + } + + setOverlayPlayPauseImage(isPlayerAvailable() && player.isPlaying()); + }); + } + + private View.OnClickListener makeOnClickListener(final Consumer consumer) { + return v -> { + if (!isLoading.get() && currentInfo != null) { + consumer.accept(currentInfo); + } + }; + } + + private void setOnLongClickListeners() { + binding.detailTitleRootLayout.setOnLongClickListener(makeOnLongClickListener(info -> + ShareUtils.copyToClipboard(requireContext(), + binding.detailVideoTitleView.getText().toString()))); + binding.detailUploaderRootLayout.setOnLongClickListener(makeOnLongClickListener(info -> { + if (isEmpty(info.getSubChannelUrl())) { + Log.w(TAG, "Can't open parent channel because we got no parent channel URL"); + } else { + openChannel(info.getUploaderUrl(), info.getUploaderName()); + } + })); + + binding.detailControlsBackground.setOnLongClickListener(makeOnLongClickListener(info -> + openBackgroundPlayer(true) + )); + binding.detailControlsPopup.setOnLongClickListener(makeOnLongClickListener(info -> + openPopupPlayer(true) + )); + binding.detailControlsDownload.setOnLongClickListener(makeOnLongClickListener(info -> + NavigationHelper.openDownloads(activity))); + + final View.OnLongClickListener overlayListener = makeOnLongClickListener(info -> + openChannel(info.getUploaderUrl(), info.getUploaderName())); + binding.overlayThumbnail.setOnLongClickListener(overlayListener); + binding.overlayMetadataLayout.setOnLongClickListener(overlayListener); + } + + private View.OnLongClickListener makeOnLongClickListener(final Consumer consumer) { + return v -> { + if (isLoading.get() || currentInfo == null) { + return false; + } + consumer.accept(currentInfo); + return true; + }; + } + + private void openChannel(final String subChannelUrl, final String subChannelName) { + try { + NavigationHelper.openChannelFragment(getFM(), currentInfo.getServiceId(), + subChannelUrl, subChannelName); + } catch (final Exception e) { + ErrorUtil.showUiErrorSnackbar(this, "Opening channel fragment", e); + } + } + + private void toggleTitleAndSecondaryControls() { + if (binding.detailSecondaryControlPanel.getVisibility() == View.GONE) { + binding.detailVideoTitleView.setMaxLines(10); + animateRotation(binding.detailToggleSecondaryControlsView, + VideoPlayerUi.DEFAULT_CONTROLS_DURATION, 180); + binding.detailSecondaryControlPanel.setVisibility(View.VISIBLE); + } else { + binding.detailVideoTitleView.setMaxLines(1); + animateRotation(binding.detailToggleSecondaryControlsView, + VideoPlayerUi.DEFAULT_CONTROLS_DURATION, 0); + binding.detailSecondaryControlPanel.setVisibility(View.GONE); + } + // view pager height has changed, update the tab layout + updateTabLayoutVisibility(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Init + //////////////////////////////////////////////////////////////////////////*/ + + @Override // called from onViewCreated in {@link BaseFragment#onViewCreated} + protected void initViews(final View rootView, final Bundle savedInstanceState) { + super.initViews(rootView, savedInstanceState); + + pageAdapter = new TabAdapter(getChildFragmentManager()); + binding.viewPager.setAdapter(pageAdapter); + binding.tabLayout.setupWithViewPager(binding.viewPager); + + binding.detailThumbnailRootLayout.requestFocus(); + + binding.detailControlsPlayWithKodi.setVisibility( + KoreUtils.shouldShowPlayWithKodi(requireContext(), serviceId) + ? View.VISIBLE + : View.GONE + ); + binding.detailControlsCrashThePlayer.setVisibility( + DEBUG && PreferenceManager.getDefaultSharedPreferences(getContext()) + .getBoolean(getString(R.string.show_crash_the_player_key), false) + ? View.VISIBLE + : View.GONE + ); + accommodateForTvAndDesktopMode(); + } + + @Override + @SuppressLint("ClickableViewAccessibility") + protected void initListeners() { + super.initListeners(); + + setOnClickListeners(); + setOnLongClickListeners(); + + final View.OnTouchListener controlsTouchListener = (view, motionEvent) -> { + if (motionEvent.getAction() == MotionEvent.ACTION_DOWN + && PlayButtonHelper.shouldShowHoldToAppendTip(activity)) { + + animate(binding.touchAppendDetail, true, 250, AnimationType.ALPHA, 0, () -> + animate(binding.touchAppendDetail, false, 1500, AnimationType.ALPHA, 1000)); + } + return false; + }; + binding.detailControlsBackground.setOnTouchListener(controlsTouchListener); + binding.detailControlsPopup.setOnTouchListener(controlsTouchListener); + + binding.appBarLayout.addOnOffsetChangedListener((layout, verticalOffset) -> { + // prevent useless updates to tab layout visibility if nothing changed + if (verticalOffset != lastAppBarVerticalOffset) { + lastAppBarVerticalOffset = verticalOffset; + // the view was scrolled + updateTabLayoutVisibility(); + } + }); + + setupBottomPlayer(); + if (!playerHolder.isBound()) { + setHeightThumbnail(); + } else { + playerHolder.startService(false, this); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // OwnStack + //////////////////////////////////////////////////////////////////////////*/ + + /** + * Stack that contains the "navigation history".
+ * The peek is the current video. + */ + private static LinkedList stack = new LinkedList<>(); + + @Override + public boolean onKeyDown(final int keyCode) { + return isPlayerAvailable() + && player.UIs().get(VideoPlayerUi.class) + .map(playerUi -> playerUi.onKeyDown(keyCode)).orElse(false); + } + + @Override + public boolean onBackPressed() { + if (DEBUG) { + Log.d(TAG, "onBackPressed() called"); + } + + // If we are in fullscreen mode just exit from it via first back press + if (isFullscreen()) { + if (!DeviceUtils.isTablet(activity)) { + player.pause(); + } + restoreDefaultOrientation(); + setAutoPlay(false); + return true; + } + + // If we have something in history of played items we replay it here + if (isPlayerAvailable() + && player.getPlayQueue() != null + && player.videoPlayerSelected() + && player.getPlayQueue().previous()) { + return true; // no code here, as previous() was used in the if + } + + // That means that we are on the start of the stack, + if (stack.size() <= 1) { + restoreDefaultOrientation(); + return false; // let MainActivity handle the onBack (e.g. to minimize the mini player) + } + + // Remove top + stack.pop(); + // Get stack item from the new top + setupFromHistoryItem(Objects.requireNonNull(stack.peek())); + + return true; + } + + private void setupFromHistoryItem(final StackItem item) { + setAutoPlay(false); + hideMainPlayerOnLoadingNewStream(); + + setInitialData(item.getServiceId(), item.getUrl(), + item.getTitle() == null ? "" : item.getTitle(), item.getPlayQueue()); + startLoading(false); + + // Maybe an item was deleted in background activity + if (item.getPlayQueue().getItem() == null) { + return; + } + + final PlayQueueItem playQueueItem = item.getPlayQueue().getItem(); + // Update title, url, uploader from the last item in the stack (it's current now) + final boolean isPlayerStopped = !isPlayerAvailable() || player.isStopped(); + if (playQueueItem != null && isPlayerStopped) { + updateOverlayData(playQueueItem.getTitle(), + playQueueItem.getUploader(), playQueueItem.getThumbnails()); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Info loading and handling + //////////////////////////////////////////////////////////////////////////*/ + + @Override + protected void doInitialLoadLogic() { + if (wasCleared()) { + return; + } + + if (currentInfo == null) { + prepareAndLoadInfo(); + } else { + prepareAndHandleInfoIfNeededAfterDelay(currentInfo, false, 50); + } + } + + public void selectAndLoadVideo(final int newServiceId, + @Nullable final String newUrl, + @NonNull final String newTitle, + @Nullable final PlayQueue newQueue) { + if (isPlayerAvailable() && newQueue != null && playQueue != null + && playQueue.getItem() != null && !playQueue.getItem().getUrl().equals(newUrl)) { + // Preloading can be disabled since playback is surely being replaced. + player.disablePreloadingOfCurrentTrack(); + } + + setInitialData(newServiceId, newUrl, newTitle, newQueue); + startLoading(false, true); + } + + private void prepareAndHandleInfoIfNeededAfterDelay(final StreamInfo info, + final boolean scrollToTop, + final long delay) { + new Handler(Looper.getMainLooper()).postDelayed(() -> { + if (activity == null) { + return; + } + // Data can already be drawn, don't spend time twice + if (info.getName().equals(binding.detailVideoTitleView.getText().toString())) { + return; + } + prepareAndHandleInfo(info, scrollToTop); + }, delay); + } + + private void prepareAndHandleInfo(final StreamInfo info, final boolean scrollToTop) { + if (DEBUG) { + Log.d(TAG, "prepareAndHandleInfo() called with: " + + "info = [" + info + "], scrollToTop = [" + scrollToTop + "]"); + } + + showLoading(); + initTabs(); + + if (scrollToTop) { + scrollToTop(); + } + handleResult(info); + showContent(); + + } + + protected void prepareAndLoadInfo() { + scrollToTop(); + startLoading(false); + } + + @Override + public void startLoading(final boolean forceLoad) { + super.startLoading(forceLoad); + + initTabs(); + currentInfo = null; + if (currentWorker != null) { + currentWorker.dispose(); + } + + runWorker(forceLoad, stack.isEmpty()); + } + + private void startLoading(final boolean forceLoad, final boolean addToBackStack) { + super.startLoading(forceLoad); + + initTabs(); + currentInfo = null; + if (currentWorker != null) { + currentWorker.dispose(); + } + + runWorker(forceLoad, addToBackStack); + } + + private void runWorker(final boolean forceLoad, final boolean addToBackStack) { + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(activity); + currentWorker = ExtractorHelper.getStreamInfo(serviceId, url, forceLoad) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(result -> { + isLoading.set(false); + hideMainPlayerOnLoadingNewStream(); + if (result.getAgeLimit() != NO_AGE_LIMIT && !prefs.getBoolean( + getString(R.string.show_age_restricted_content), false)) { + hideAgeRestrictedContent(); + } else { + handleResult(result); + showContent(); + if (addToBackStack) { + if (playQueue == null) { + playQueue = new SinglePlayQueue(result); + } + if (stack.isEmpty() || !stack.peek().getPlayQueue() + .equalStreams(playQueue)) { + stack.push(new StackItem(serviceId, url, title, playQueue)); + } + } + + if (isAutoplayEnabled()) { + openVideoPlayerAutoFullscreen(); + } + } + }, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_STREAM, + url == null ? "no url" : url, serviceId))); + } + + /*////////////////////////////////////////////////////////////////////////// + // Tabs + //////////////////////////////////////////////////////////////////////////*/ + + private void initTabs() { + if (pageAdapter.getCount() != 0) { + selectedTabTag = pageAdapter.getItemTitle(binding.viewPager.getCurrentItem()); + } + pageAdapter.clearAllItems(); + tabIcons.clear(); + tabContentDescriptions.clear(); + + if (shouldShowComments()) { + pageAdapter.addFragment( + CommentsFragment.getInstance(serviceId, url, title), COMMENTS_TAB_TAG); + tabIcons.add(R.drawable.ic_comment); + tabContentDescriptions.add(R.string.comments_tab_description); + } + + if (showRelatedItems && binding.relatedItemsLayout == null) { + // temp empty fragment. will be updated in handleResult + pageAdapter.addFragment(EmptyFragment.newInstance(false), RELATED_TAB_TAG); + tabIcons.add(R.drawable.ic_art_track); + tabContentDescriptions.add(R.string.related_items_tab_description); + } + + if (showDescription) { + // temp empty fragment. will be updated in handleResult + pageAdapter.addFragment(EmptyFragment.newInstance(false), DESCRIPTION_TAB_TAG); + tabIcons.add(R.drawable.ic_description); + tabContentDescriptions.add(R.string.description_tab_description); + } + + if (pageAdapter.getCount() == 0) { + pageAdapter.addFragment(EmptyFragment.newInstance(true), EMPTY_TAB_TAG); + } + pageAdapter.notifyDataSetUpdate(); + + if (pageAdapter.getCount() >= 2) { + final int position = pageAdapter.getItemPositionByTitle(selectedTabTag); + if (position != -1) { + binding.viewPager.setCurrentItem(position); + } + updateTabIconsAndContentDescriptions(); + } + // the page adapter now contains tabs: show the tab layout + updateTabLayoutVisibility(); + } + + /** + * To be called whenever {@link #pageAdapter} is modified, since that triggers a refresh in + * {@link FragmentVideoDetailBinding#tabLayout} resetting all tab's icons and content + * descriptions. This reads icons from {@link #tabIcons} and content descriptions from + * {@link #tabContentDescriptions}, which are all set in {@link #initTabs()}. + */ + private void updateTabIconsAndContentDescriptions() { + for (int i = 0; i < tabIcons.size(); ++i) { + final TabLayout.Tab tab = binding.tabLayout.getTabAt(i); + if (tab != null) { + tab.setIcon(tabIcons.get(i)); + tab.setContentDescription(tabContentDescriptions.get(i)); + } + } + } + + private void updateTabs(@NonNull final StreamInfo info) { + if (showRelatedItems) { + if (binding.relatedItemsLayout == null) { // phone + pageAdapter.updateItem(RELATED_TAB_TAG, RelatedItemsFragment.getInstance(info)); + } else { // tablet + TV + getChildFragmentManager().beginTransaction() + .replace(R.id.relatedItemsLayout, RelatedItemsFragment.getInstance(info)) + .commitAllowingStateLoss(); + binding.relatedItemsLayout.setVisibility(isFullscreen() ? View.GONE : View.VISIBLE); + } + } + + if (showDescription) { + pageAdapter.updateItem(DESCRIPTION_TAB_TAG, new DescriptionFragment(info)); + } + + binding.viewPager.setVisibility(View.VISIBLE); + // make sure the tab layout is visible + updateTabLayoutVisibility(); + pageAdapter.notifyDataSetUpdate(); + updateTabIconsAndContentDescriptions(); + } + + private boolean shouldShowComments() { + try { + return showComments && NewPipe.getService(serviceId) + .getServiceInfo() + .getMediaCapabilities() + .contains(COMMENTS); + } catch (final ExtractionException e) { + return false; + } + } + + public void updateTabLayoutVisibility() { + + if (binding == null) { + //If binding is null we do not need to and should not do anything with its object(s) + return; + } + + if (pageAdapter.getCount() < 2 || binding.viewPager.getVisibility() != View.VISIBLE) { + // hide tab layout if there is only one tab or if the view pager is also hidden + binding.tabLayout.setVisibility(View.GONE); + } else { + // call `post()` to be sure `viewPager.getHitRect()` + // is up to date and not being currently recomputed + binding.tabLayout.post(() -> { + final var activity = getActivity(); + if (activity != null) { + final Rect pagerHitRect = new Rect(); + binding.viewPager.getHitRect(pagerHitRect); + + final int height = DeviceUtils.getWindowHeight(activity.getWindowManager()); + final int viewPagerVisibleHeight = height - pagerHitRect.top; + // see TabLayout.DEFAULT_HEIGHT, which is equal to 48dp + final float tabLayoutHeight = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, 48, getResources().getDisplayMetrics()); + + if (viewPagerVisibleHeight > tabLayoutHeight * 2) { + // no translation at all when viewPagerVisibleHeight > tabLayout.height * 3 + binding.tabLayout.setTranslationY( + Math.max(0, tabLayoutHeight * 3 - viewPagerVisibleHeight)); + binding.tabLayout.setVisibility(View.VISIBLE); + } else { + // view pager is not visible enough + binding.tabLayout.setVisibility(View.GONE); + } + } + }); + } + } + + public void scrollToTop() { + binding.appBarLayout.setExpanded(true, true); + // notify tab layout of scrolling + updateTabLayoutVisibility(); + } + + public void scrollToComment(final CommentsInfoItem comment) { + final int commentsTabPos = pageAdapter.getItemPositionByTitle(COMMENTS_TAB_TAG); + final Fragment fragment = pageAdapter.getItem(commentsTabPos); + if (!(fragment instanceof CommentsFragment)) { + return; + } + + // unexpand the app bar only if scrolling to the comment succeeded + if (((CommentsFragment) fragment).scrollToComment(comment)) { + binding.appBarLayout.setExpanded(false, false); + binding.viewPager.setCurrentItem(commentsTabPos, false); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Play Utils + //////////////////////////////////////////////////////////////////////////*/ + + private void toggleFullscreenIfInFullscreenMode() { + // If a user watched video inside fullscreen mode and than chose another player + // return to non-fullscreen mode + if (isPlayerAvailable()) { + player.UIs().get(MainPlayerUi.class).ifPresent(playerUi -> { + if (playerUi.isFullscreen()) { + playerUi.toggleFullscreen(); + } + }); + } + } + + private void openBackgroundPlayer(final boolean append) { + final boolean useExternalAudioPlayer = PreferenceManager + .getDefaultSharedPreferences(activity) + .getBoolean(activity.getString(R.string.use_external_audio_player_key), false); + + toggleFullscreenIfInFullscreenMode(); + + if (isPlayerAvailable()) { + // FIXME Workaround #7427 + player.setRecovery(); + } + + if (useExternalAudioPlayer) { + showExternalAudioPlaybackDialog(); + } else { + openNormalBackgroundPlayer(append); + } + } + + private void openPopupPlayer(final boolean append) { + if (!PermissionHelper.isPopupEnabledElseAsk(activity)) { + return; + } + + // See UI changes while remote playQueue changes + if (!isPlayerAvailable()) { + playerHolder.startService(false, this); + } else { + // FIXME Workaround #7427 + player.setRecovery(); + } + + toggleFullscreenIfInFullscreenMode(); + + final PlayQueue queue = setupPlayQueueForIntent(append); + if (append) { //resumePlayback: false + NavigationHelper.enqueueOnPlayer(activity, queue, PlayerType.POPUP); + } else { + replaceQueueIfUserConfirms(() -> NavigationHelper + .playOnPopupPlayer(activity, queue, true)); + } + } + + /** + * Opens the video player, in fullscreen if needed. In order to open fullscreen, the activity + * is toggled to landscape orientation (which will then cause fullscreen mode). + * + * @param directlyFullscreenIfApplicable whether to open fullscreen if we are not already + * in landscape and screen orientation is locked + */ + public void openVideoPlayer(final boolean directlyFullscreenIfApplicable) { + if (directlyFullscreenIfApplicable + && !DeviceUtils.isLandscape(requireContext()) + && PlayerHelper.globalScreenOrientationLocked(requireContext())) { + // Make sure the bottom sheet turns out expanded. When this code kicks in the bottom + // sheet could not have fully expanded yet, and thus be in the STATE_SETTLING state. + // When the activity is rotated, and its state is saved and then restored, the bottom + // sheet would forget what it was doing, since even if STATE_SETTLING is restored, it + // doesn't tell which state it was settling to, and thus the bottom sheet settles to + // STATE_COLLAPSED. This can be solved by manually setting the state that will be + // restored (i.e. bottomSheetState) to STATE_EXPANDED. + updateBottomSheetState(BottomSheetBehavior.STATE_EXPANDED); + // toggle landscape in order to open directly in fullscreen + onScreenRotationButtonClicked(); + } + + if (PreferenceManager.getDefaultSharedPreferences(activity) + .getBoolean(this.getString(R.string.use_external_video_player_key), false)) { + showExternalVideoPlaybackDialog(); + } else { + replaceQueueIfUserConfirms(this::openMainPlayer); + } + } + + /** + * If the option to start directly fullscreen is enabled, calls + * {@link #openVideoPlayer(boolean)} with {@code directlyFullscreenIfApplicable = true}, so that + * if the user is not already in landscape and he has screen orientation locked the activity + * rotates and fullscreen starts. Otherwise, if the option to start directly fullscreen is + * disabled, calls {@link #openVideoPlayer(boolean)} with {@code directlyFullscreenIfApplicable + * = false}, hence preventing it from going directly fullscreen. + */ + public void openVideoPlayerAutoFullscreen() { + openVideoPlayer(PlayerHelper.isStartMainPlayerFullscreenEnabled(requireContext())); + } + + private void openNormalBackgroundPlayer(final boolean append) { + // See UI changes while remote playQueue changes + if (!isPlayerAvailable()) { + playerHolder.startService(false, this); + } + + final PlayQueue queue = setupPlayQueueForIntent(append); + if (append) { + NavigationHelper.enqueueOnPlayer(activity, queue, PlayerType.AUDIO); + } else { + replaceQueueIfUserConfirms(() -> NavigationHelper + .playOnBackgroundPlayer(activity, queue, true)); + } + } + + private void openMainPlayer() { + if (!isPlayerServiceAvailable()) { + playerHolder.startService(autoPlayEnabled, this); + return; + } + if (currentInfo == null) { + return; + } + + final PlayQueue queue = setupPlayQueueForIntent(false); + tryAddVideoPlayerView(); + + final Intent playerIntent = NavigationHelper.getPlayerIntent(requireContext(), + PlayerService.class, queue, true, autoPlayEnabled); + ContextCompat.startForegroundService(activity, playerIntent); + } + + /** + * When the video detail fragment is already showing details for a video and the user opens a + * new one, the video detail fragment changes all of its old data to the new stream, so if there + * is a video player currently open it should be hidden. This method does exactly that. If + * autoplay is enabled, the underlying player is not stopped completely, since it is going to + * be reused in a few milliseconds and the flickering would be annoying. + */ + private void hideMainPlayerOnLoadingNewStream() { + final var root = getRoot(); + if (!isPlayerServiceAvailable() || root.isEmpty() || !player.videoPlayerSelected()) { + return; + } + + removeVideoPlayerView(); + if (isAutoplayEnabled()) { + playerService.stopForImmediateReusing(); + root.ifPresent(view -> view.setVisibility(View.GONE)); + } else { + playerHolder.stopService(); + } + } + + private PlayQueue setupPlayQueueForIntent(final boolean append) { + if (append) { + return new SinglePlayQueue(currentInfo); + } + + PlayQueue queue = playQueue; + // Size can be 0 because queue removes bad stream automatically when error occurs + if (queue == null || queue.isEmpty()) { + queue = new SinglePlayQueue(currentInfo); + } + + return queue; + } + + /*////////////////////////////////////////////////////////////////////////// + // Utils + //////////////////////////////////////////////////////////////////////////*/ + + public void setAutoPlay(final boolean autoPlay) { + this.autoPlayEnabled = autoPlay; + } + + private void startOnExternalPlayer(@NonNull final Context context, + @NonNull final StreamInfo info, + @NonNull final Stream selectedStream) { + NavigationHelper.playOnExternalPlayer(context, currentInfo.getName(), + currentInfo.getSubChannelName(), selectedStream); + + final HistoryRecordManager recordManager = new HistoryRecordManager(requireContext()); + disposables.add(recordManager.onViewed(info).onErrorComplete() + .subscribe( + ignored -> { /* successful */ }, + error -> Log.e(TAG, "Register view failure: ", error) + )); + } + + private boolean isExternalPlayerEnabled() { + return PreferenceManager.getDefaultSharedPreferences(requireContext()) + .getBoolean(getString(R.string.use_external_video_player_key), false); + } + + // This method overrides default behaviour when setAutoPlay() is called. + // Don't auto play if the user selected an external player or disabled it in settings + private boolean isAutoplayEnabled() { + return autoPlayEnabled + && !isExternalPlayerEnabled() + && (!isPlayerAvailable() || player.videoPlayerSelected()) + && bottomSheetState != BottomSheetBehavior.STATE_HIDDEN + && PlayerHelper.isAutoplayAllowedByUser(requireContext()); + } + + private void tryAddVideoPlayerView() { + if (isPlayerAvailable() && getView() != null) { + // Setup the surface view height, so that it fits the video correctly; this is done also + // here, and not only in the Handler, to avoid a choppy fullscreen rotation animation. + setHeightThumbnail(); + } + + // do all the null checks in the posted lambda, too, since the player, the binding and the + // view could be set or unset before the lambda gets executed on the next main thread cycle + new Handler(Looper.getMainLooper()).post(() -> { + if (!isPlayerAvailable() || getView() == null) { + return; + } + + // setup the surface view height, so that it fits the video correctly + setHeightThumbnail(); + + player.UIs().get(MainPlayerUi.class).ifPresent(playerUi -> { + // sometimes binding would be null here, even though getView() != null above u.u + if (binding != null) { + // prevent from re-adding a view multiple times + playerUi.removeViewFromParent(); + binding.playerPlaceholder.addView(playerUi.getBinding().getRoot()); + playerUi.setupVideoSurfaceIfNeeded(); + } + }); + }); + } + + private void removeVideoPlayerView() { + makeDefaultHeightForVideoPlaceholder(); + + if (player != null) { + player.UIs().get(VideoPlayerUi.class).ifPresent(VideoPlayerUi::removeViewFromParent); + } + } + + private void makeDefaultHeightForVideoPlaceholder() { + if (getView() == null) { + return; + } + + binding.playerPlaceholder.getLayoutParams().height = FrameLayout.LayoutParams.MATCH_PARENT; + binding.playerPlaceholder.requestLayout(); + } + + private final ViewTreeObserver.OnPreDrawListener preDrawListener = + new ViewTreeObserver.OnPreDrawListener() { + @Override + public boolean onPreDraw() { + final DisplayMetrics metrics = getResources().getDisplayMetrics(); + + if (getView() != null) { + final int height = (DeviceUtils.isInMultiWindow(activity) + ? requireView() + : activity.getWindow().getDecorView()).getHeight(); + setHeightThumbnail(height, metrics); + getView().getViewTreeObserver().removeOnPreDrawListener(preDrawListener); + } + return false; + } + }; + + /** + * Method which controls the size of thumbnail and the size of main player inside + * a layout with thumbnail. It decides what height the player should have in both + * screen orientations. It knows about multiWindow feature + * and about videos with aspectRatio ZOOM (the height for them will be a bit higher, + * {@link #MAX_PLAYER_HEIGHT}) + */ + private void setHeightThumbnail() { + final DisplayMetrics metrics = getResources().getDisplayMetrics(); + final boolean isPortrait = metrics.heightPixels > metrics.widthPixels; + requireView().getViewTreeObserver().removeOnPreDrawListener(preDrawListener); + + if (isFullscreen()) { + final int height = (DeviceUtils.isInMultiWindow(activity) + ? requireView() + : activity.getWindow().getDecorView()).getHeight(); + // Height is zero when the view is not yet displayed like after orientation change + if (height != 0) { + setHeightThumbnail(height, metrics); + } else { + requireView().getViewTreeObserver().addOnPreDrawListener(preDrawListener); + } + } else { + final int height = (int) (isPortrait + ? metrics.widthPixels / (16.0f / 9.0f) + : metrics.heightPixels / 2.0f); + setHeightThumbnail(height, metrics); + } + } + + private void setHeightThumbnail(final int newHeight, final DisplayMetrics metrics) { + binding.detailThumbnailImageView.setLayoutParams( + new FrameLayout.LayoutParams( + RelativeLayout.LayoutParams.MATCH_PARENT, newHeight)); + binding.detailThumbnailImageView.setMinimumHeight(newHeight); + if (isPlayerAvailable()) { + final int maxHeight = (int) (metrics.heightPixels * MAX_PLAYER_HEIGHT); + player.UIs().get(VideoPlayerUi.class).ifPresent(ui -> + ui.getBinding().surfaceView.setHeights(newHeight, + ui.isFullscreen() ? newHeight : maxHeight)); + } + } + + private void showContent() { + binding.detailContentRootHiding.setVisibility(View.VISIBLE); + } + + protected void setInitialData(final int newServiceId, + @Nullable final String newUrl, + @NonNull final String newTitle, + @Nullable final PlayQueue newPlayQueue) { + this.serviceId = newServiceId; + this.url = newUrl; + this.title = newTitle; + this.playQueue = newPlayQueue; + } + + private void setErrorImage(final int imageResource) { + if (binding == null || activity == null) { + return; + } + + binding.detailThumbnailImageView.setImageDrawable( + AppCompatResources.getDrawable(requireContext(), imageResource)); + animate(binding.detailThumbnailImageView, false, 0, AnimationType.ALPHA, + 0, () -> animate(binding.detailThumbnailImageView, true, 500)); + } + + @Override + public void handleError() { + super.handleError(); + setErrorImage(R.drawable.not_available_monkey); + + if (binding.relatedItemsLayout != null) { // hide related streams for tablets + binding.relatedItemsLayout.setVisibility(View.INVISIBLE); + } + + // hide comments / related streams / description tabs + binding.viewPager.setVisibility(View.GONE); + binding.tabLayout.setVisibility(View.GONE); + } + + private void hideAgeRestrictedContent() { + showTextError(getString(R.string.restricted_video, + getString(R.string.show_age_restricted_content_title))); + } + + private void setupBroadcastReceiver() { + broadcastReceiver = new BroadcastReceiver() { + @Override + public void onReceive(final Context context, final Intent intent) { + switch (intent.getAction()) { + case ACTION_SHOW_MAIN_PLAYER: + bottomSheetBehavior.setState(BottomSheetBehavior.STATE_EXPANDED); + break; + case ACTION_HIDE_MAIN_PLAYER: + bottomSheetBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); + break; + case ACTION_PLAYER_STARTED: + // If the state is not hidden we don't need to show the mini player + if (bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_HIDDEN) { + bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); + } + // Rebound to the service if it was closed via notification or mini player + if (!playerHolder.isBound()) { + playerHolder.startService( + false, VideoDetailFragment.this); + } + break; + } + } + }; + final IntentFilter intentFilter = new IntentFilter(); + intentFilter.addAction(ACTION_SHOW_MAIN_PLAYER); + intentFilter.addAction(ACTION_HIDE_MAIN_PLAYER); + intentFilter.addAction(ACTION_PLAYER_STARTED); + activity.registerReceiver(broadcastReceiver, intentFilter); + } + + + /*////////////////////////////////////////////////////////////////////////// + // Orientation listener + //////////////////////////////////////////////////////////////////////////*/ + + private void restoreDefaultOrientation() { + if (isPlayerAvailable() && player.videoPlayerSelected()) { + toggleFullscreenIfInFullscreenMode(); + } + + // This will show systemUI and pause the player. + // User can tap on Play button and video will be in fullscreen mode again + // Note for tablet: trying to avoid orientation changes since it's not easy + // to physically rotate the tablet every time + if (activity != null && !DeviceUtils.isTablet(activity)) { + activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Contract + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void showLoading() { + + super.showLoading(); + + //if data is already cached, transition from VISIBLE -> INVISIBLE -> VISIBLE is not required + if (!ExtractorHelper.isCached(serviceId, url, InfoCache.Type.STREAM)) { + binding.detailContentRootHiding.setVisibility(View.INVISIBLE); + } + + animate(binding.detailThumbnailPlayButton, false, 50); + animate(binding.detailDurationView, false, 100); + binding.detailPositionView.setVisibility(View.GONE); + binding.positionView.setVisibility(View.GONE); + + binding.detailVideoTitleView.setText(title); + binding.detailVideoTitleView.setMaxLines(1); + animate(binding.detailVideoTitleView, true, 0); + + binding.detailToggleSecondaryControlsView.setVisibility(View.GONE); + binding.detailTitleRootLayout.setClickable(false); + binding.detailSecondaryControlPanel.setVisibility(View.GONE); + + if (binding.relatedItemsLayout != null) { + if (showRelatedItems) { + binding.relatedItemsLayout.setVisibility( + isFullscreen() ? View.GONE : View.INVISIBLE); + } else { + binding.relatedItemsLayout.setVisibility(View.GONE); + } + } + + PicassoHelper.cancelTag(PICASSO_VIDEO_DETAILS_TAG); + binding.detailThumbnailImageView.setImageBitmap(null); + binding.detailSubChannelThumbnailView.setImageBitmap(null); + } + + @Override + public void handleResult(@NonNull final StreamInfo info) { + super.handleResult(info); + + currentInfo = info; + setInitialData(info.getServiceId(), info.getOriginalUrl(), info.getName(), playQueue); + + updateTabs(info); + + animate(binding.detailThumbnailPlayButton, true, 200); + binding.detailVideoTitleView.setText(title); + + binding.detailSubChannelThumbnailView.setVisibility(View.GONE); + + if (!isEmpty(info.getSubChannelName())) { + displayBothUploaderAndSubChannel(info); + } else { + displayUploaderAsSubChannel(info); + } + + if (info.getViewCount() >= 0) { + if (info.getStreamType().equals(StreamType.AUDIO_LIVE_STREAM)) { + binding.detailViewCountView.setText(Localization.listeningCount(activity, + info.getViewCount())); + } else if (info.getStreamType().equals(StreamType.LIVE_STREAM)) { + binding.detailViewCountView.setText(Localization + .localizeWatchingCount(activity, info.getViewCount())); + } else { + binding.detailViewCountView.setText(Localization + .localizeViewCount(activity, info.getViewCount())); + } + binding.detailViewCountView.setVisibility(View.VISIBLE); + } else { + binding.detailViewCountView.setVisibility(View.GONE); + } + + if (info.getDislikeCount() == -1 && info.getLikeCount() == -1) { + binding.detailThumbsDownImgView.setVisibility(View.VISIBLE); + binding.detailThumbsUpImgView.setVisibility(View.VISIBLE); + binding.detailThumbsUpCountView.setVisibility(View.GONE); + binding.detailThumbsDownCountView.setVisibility(View.GONE); + + binding.detailThumbsDisabledView.setVisibility(View.VISIBLE); + } else { + if (info.getDislikeCount() == -1) { + new Thread(() -> { + info.setDislikeCount(ReturnYouTubeDislikeUtils.getDislikes(getContext(), info)); + if (info.getDislikeCount() >= 0) { + if (activity == null) { + return; + } + activity.runOnUiThread(() -> { + if (binding != null && binding.detailThumbsDownCountView != null) { + binding.detailThumbsDownCountView.setText(Localization + .shortCount(activity, info.getDislikeCount())); + binding.detailThumbsDownCountView.setVisibility(View.VISIBLE); + } + if (binding != null && binding.detailThumbsDownImgView != null) { + binding.detailThumbsDownImgView.setVisibility(View.VISIBLE); + } + }); + } + }).start(); + } + if (info.getDislikeCount() >= 0) { + binding.detailThumbsDownCountView.setText(Localization + .shortCount(activity, info.getDislikeCount())); + binding.detailThumbsDownCountView.setVisibility(View.VISIBLE); + binding.detailThumbsDownImgView.setVisibility(View.VISIBLE); + } else { + binding.detailThumbsDownCountView.setVisibility(View.GONE); + binding.detailThumbsDownImgView.setVisibility(View.GONE); + } + + if (info.getLikeCount() >= 0) { + binding.detailThumbsUpCountView.setText(Localization.shortCount(activity, + info.getLikeCount())); + binding.detailThumbsUpCountView.setVisibility(View.VISIBLE); + binding.detailThumbsUpImgView.setVisibility(View.VISIBLE); + } else { + binding.detailThumbsUpCountView.setVisibility(View.GONE); + binding.detailThumbsUpImgView.setVisibility(View.GONE); + } + binding.detailThumbsDisabledView.setVisibility(View.GONE); + } + + if (info.getDuration() > 0) { + binding.detailDurationView.setText(Localization.getDurationString(info.getDuration())); + binding.detailDurationView.setBackgroundColor( + ContextCompat.getColor(activity, R.color.duration_background_color)); + animate(binding.detailDurationView, true, 100); + } else if (info.getStreamType() == StreamType.LIVE_STREAM) { + binding.detailDurationView.setText(R.string.duration_live); + binding.detailDurationView.setBackgroundColor( + ContextCompat.getColor(activity, R.color.live_duration_background_color)); + animate(binding.detailDurationView, true, 100); + } else { + binding.detailDurationView.setVisibility(View.GONE); + } + + binding.detailTitleRootLayout.setClickable(true); + binding.detailToggleSecondaryControlsView.setRotation(0); + binding.detailToggleSecondaryControlsView.setVisibility(View.VISIBLE); + binding.detailSecondaryControlPanel.setVisibility(View.GONE); + + checkUpdateProgressInfo(info); + PicassoHelper.loadDetailsThumbnail(info.getThumbnails()).tag(PICASSO_VIDEO_DETAILS_TAG) + .into(binding.detailThumbnailImageView); + showMetaInfoInTextView(info.getMetaInfo(), binding.detailMetaInfoTextView, + binding.detailMetaInfoSeparator, disposables); + + if (!isPlayerAvailable() || player.isStopped()) { + updateOverlayData(info.getName(), info.getUploaderName(), info.getThumbnails()); + } + + if (!info.getErrors().isEmpty()) { + // Bandcamp fan pages are not yet supported and thus a ContentNotAvailableException is + // thrown. This is not an error and thus should not be shown to the user. + for (final Throwable throwable : info.getErrors()) { + if (throwable instanceof ContentNotSupportedException + && "Fan pages are not supported".equals(throwable.getMessage())) { + info.getErrors().remove(throwable); + } + } + + if (!info.getErrors().isEmpty()) { + showSnackBarError(new ErrorInfo(info.getErrors(), + UserAction.REQUESTED_STREAM, info.getUrl(), info)); + } + } + + binding.detailControlsDownload.setVisibility( + StreamTypeUtil.isLiveStream(info.getStreamType()) ? View.GONE : View.VISIBLE); + binding.detailControlsBackground.setVisibility( + info.getAudioStreams().isEmpty() && info.getVideoStreams().isEmpty() + ? View.GONE : View.VISIBLE); + + final boolean noVideoStreams = + info.getVideoStreams().isEmpty() && info.getVideoOnlyStreams().isEmpty(); + binding.detailControlsPopup.setVisibility(noVideoStreams ? View.GONE : View.VISIBLE); + binding.detailThumbnailPlayButton.setImageResource( + noVideoStreams ? R.drawable.ic_headset_shadow : R.drawable.ic_play_arrow_shadow); + } + + private void displayUploaderAsSubChannel(final StreamInfo info) { + binding.detailSubChannelTextView.setText(info.getUploaderName()); + binding.detailSubChannelTextView.setVisibility(View.VISIBLE); + binding.detailSubChannelTextView.setSelected(true); + + if (info.getUploaderSubscriberCount() > -1) { + binding.detailUploaderTextView.setText( + Localization.shortSubscriberCount(activity, info.getUploaderSubscriberCount())); + binding.detailUploaderTextView.setVisibility(View.VISIBLE); + } else { + binding.detailUploaderTextView.setVisibility(View.GONE); + } + + PicassoHelper.loadAvatar(info.getUploaderAvatars()).tag(PICASSO_VIDEO_DETAILS_TAG) + .into(binding.detailSubChannelThumbnailView); + binding.detailSubChannelThumbnailView.setVisibility(View.VISIBLE); + binding.detailUploaderThumbnailView.setVisibility(View.GONE); + } + + private void displayBothUploaderAndSubChannel(final StreamInfo info) { + binding.detailSubChannelTextView.setText(info.getSubChannelName()); + binding.detailSubChannelTextView.setVisibility(View.VISIBLE); + binding.detailSubChannelTextView.setSelected(true); + + final StringBuilder subText = new StringBuilder(); + if (!isEmpty(info.getUploaderName())) { + subText.append( + String.format(getString(R.string.video_detail_by), info.getUploaderName())); + } + if (info.getUploaderSubscriberCount() > -1) { + if (subText.length() > 0) { + subText.append(Localization.DOT_SEPARATOR); + } + subText.append( + Localization.shortSubscriberCount(activity, info.getUploaderSubscriberCount())); + } + + if (subText.length() > 0) { + binding.detailUploaderTextView.setText(subText); + binding.detailUploaderTextView.setVisibility(View.VISIBLE); + binding.detailUploaderTextView.setSelected(true); + } else { + binding.detailUploaderTextView.setVisibility(View.GONE); + } + + PicassoHelper.loadAvatar(info.getSubChannelAvatars()).tag(PICASSO_VIDEO_DETAILS_TAG) + .into(binding.detailSubChannelThumbnailView); + binding.detailSubChannelThumbnailView.setVisibility(View.VISIBLE); + PicassoHelper.loadAvatar(info.getUploaderAvatars()).tag(PICASSO_VIDEO_DETAILS_TAG) + .into(binding.detailUploaderThumbnailView); + binding.detailUploaderThumbnailView.setVisibility(View.VISIBLE); + } + + public void openDownloadDialog() { + if (currentInfo == null) { + return; + } + + try { + final DownloadDialog downloadDialog = new DownloadDialog(activity, currentInfo); + downloadDialog.show(activity.getSupportFragmentManager(), "downloadDialog"); + } catch (final Exception e) { + ErrorUtil.showSnackbar(activity, new ErrorInfo(e, UserAction.DOWNLOAD_OPEN_DIALOG, + "Showing download dialog", currentInfo)); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Stream Results + //////////////////////////////////////////////////////////////////////////*/ + + private void checkUpdateProgressInfo(@NonNull final StreamInfo info) { + if (positionSubscriber != null) { + positionSubscriber.dispose(); + } + if (!getResumePlaybackEnabled(activity)) { + binding.positionView.setVisibility(View.GONE); + binding.detailPositionView.setVisibility(View.GONE); + return; + } + final HistoryRecordManager recordManager = new HistoryRecordManager(requireContext()); + positionSubscriber = recordManager.loadStreamState(info) + .subscribeOn(Schedulers.io()) + .onErrorComplete() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(state -> { + updatePlaybackProgress( + state.getProgressMillis(), info.getDuration() * 1000); + }, e -> { + // impossible since the onErrorComplete() + }, () -> { + binding.positionView.setVisibility(View.GONE); + binding.detailPositionView.setVisibility(View.GONE); + }); + } + + private void updatePlaybackProgress(final long progress, final long duration) { + if (!getResumePlaybackEnabled(activity)) { + return; + } + final int progressSeconds = (int) TimeUnit.MILLISECONDS.toSeconds(progress); + final int durationSeconds = (int) TimeUnit.MILLISECONDS.toSeconds(duration); + // If the old and the new progress values have a big difference then use animation. + // Otherwise don't because it affects CPU + final int progressDifference = Math.abs(binding.positionView.getProgress() + - progressSeconds); + binding.positionView.setMax(durationSeconds); + if (progressDifference > 2) { + binding.positionView.setProgressAnimated(progressSeconds); + } else { + binding.positionView.setProgress(progressSeconds); + } + final String position = Localization.getDurationString(progressSeconds); + if (position != binding.detailPositionView.getText()) { + binding.detailPositionView.setText(position); + } + if (binding.positionView.getVisibility() != View.VISIBLE) { + animate(binding.positionView, true, 100); + animate(binding.detailPositionView, true, 100); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Player event listener + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onViewCreated() { + tryAddVideoPlayerView(); + } + + @Override + public void onQueueUpdate(final PlayQueue queue) { + playQueue = queue; + if (DEBUG) { + Log.d(TAG, "onQueueUpdate() called with: serviceId = [" + + serviceId + "], videoUrl = [" + url + "], name = [" + + title + "], playQueue = [" + playQueue + "]"); + } + + // Register broadcast receiver to listen to playQueue changes + // and hide the overlayPlayQueueButton when the playQueue is empty / destroyed. + if (playQueue != null && playQueue.getBroadcastReceiver() != null) { + playQueue.getBroadcastReceiver().subscribe( + event -> updateOverlayPlayQueueButtonVisibility() + ); + } + + // This should be the only place where we push data to stack. + // It will allow to have live instance of PlayQueue with actual information about + // deleted/added items inside Channel/Playlist queue and makes possible to have + // a history of played items + @Nullable final StackItem stackPeek = stack.peek(); + if (stackPeek != null && !stackPeek.getPlayQueue().equalStreams(queue)) { + @Nullable final PlayQueueItem playQueueItem = queue.getItem(); + if (playQueueItem != null) { + stack.push(new StackItem(playQueueItem.getServiceId(), playQueueItem.getUrl(), + playQueueItem.getTitle(), queue)); + return; + } // else continue below + } + + @Nullable final StackItem stackWithQueue = findQueueInStack(queue); + if (stackWithQueue != null) { + // On every MainPlayer service's destroy() playQueue gets disposed and + // no longer able to track progress. That's why we update our cached disposed + // queue with the new one that is active and have the same history. + // Without that the cached playQueue will have an old recovery position + stackWithQueue.setPlayQueue(queue); + } + } + + @Override + public void onPlaybackUpdate(final int state, + final int repeatMode, + final boolean shuffled, + final PlaybackParameters parameters) { + setOverlayPlayPauseImage(player != null && player.isPlaying()); + + switch (state) { + case Player.STATE_PLAYING: + if (binding.positionView.getAlpha() != 1.0f + && player.getPlayQueue() != null + && player.getPlayQueue().getItem() != null + && player.getPlayQueue().getItem().getUrl().equals(url)) { + animate(binding.positionView, true, 100); + animate(binding.detailPositionView, true, 100); + } + break; + } + } + + @Override + public void onProgressUpdate(final int currentProgress, + final int duration, + final int bufferPercent) { + // Progress updates every second even if media is paused. It's useless until playing + if (!player.isPlaying() || playQueue == null) { + return; + } + + if (player.getPlayQueue().getItem().getUrl().equals(url)) { + updatePlaybackProgress(currentProgress, duration); + } + } + + @Override + public void onMetadataUpdate(final StreamInfo info, final PlayQueue queue) { + final StackItem item = findQueueInStack(queue); + if (item != null) { + // When PlayQueue can have multiple streams (PlaylistPlayQueue or ChannelPlayQueue) + // every new played stream gives new title and url. + // StackItem contains information about first played stream. Let's update it here + item.setTitle(info.getName()); + item.setUrl(info.getUrl()); + } + // They are not equal when user watches something in popup while browsing in fragment and + // then changes screen orientation. In that case the fragment will set itself as + // a service listener and will receive initial call to onMetadataUpdate() + if (!queue.equalStreams(playQueue)) { + return; + } + + updateOverlayData(info.getName(), info.getUploaderName(), info.getThumbnails()); + if (currentInfo != null && info.getUrl().equals(currentInfo.getUrl())) { + return; + } + + currentInfo = info; + setInitialData(info.getServiceId(), info.getUrl(), info.getName(), queue); + setAutoPlay(false); + // Delay execution just because it freezes the main thread, and while playing + // next/previous video you see visual glitches + // (when non-vertical video goes after vertical video) + prepareAndHandleInfoIfNeededAfterDelay(info, true, 200); + } + + @Override + public void onPlayerError(final PlaybackException error, final boolean isCatchableException) { + if (!isCatchableException) { + // Properly exit from fullscreen + toggleFullscreenIfInFullscreenMode(); + hideMainPlayerOnLoadingNewStream(); + } + } + + @Override + public void onServiceStopped() { + setOverlayPlayPauseImage(false); + if (currentInfo != null) { + updateOverlayData(currentInfo.getName(), + currentInfo.getUploaderName(), + currentInfo.getThumbnails()); + } + updateOverlayPlayQueueButtonVisibility(); + } + + @Override + public void onFullscreenStateChanged(final boolean fullscreen) { + setupBrightness(); + if (!isPlayerAndPlayerServiceAvailable() + || player.UIs().get(MainPlayerUi.class).isEmpty() + || getRoot().map(View::getParent).isEmpty()) { + return; + } + + if (fullscreen) { + hideSystemUiIfNeeded(); + binding.overlayPlayPauseButton.requestFocus(); + } else { + showSystemUi(); + } + + if (binding.relatedItemsLayout != null) { + binding.relatedItemsLayout.setVisibility(fullscreen ? View.GONE : View.VISIBLE); + } + scrollToTop(); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + tryAddVideoPlayerView(); + } else { + // KitKat needs a delay before addVideoPlayerView call or it reports wrong height in + // activity.getWindow().getDecorView().getHeight() + new Handler().post(this::tryAddVideoPlayerView); + } + } + + @Override + public void onScreenRotationButtonClicked() { + // In tablet user experience will be better if screen will not be rotated + // from landscape to portrait every time. + // Just turn on fullscreen mode in landscape orientation + // or portrait & unlocked global orientation + final boolean isLandscape = DeviceUtils.isLandscape(requireContext()); + if (DeviceUtils.isTablet(activity) + && (!globalScreenOrientationLocked(activity) || isLandscape)) { + player.UIs().get(MainPlayerUi.class).ifPresent(MainPlayerUi::toggleFullscreen); + return; + } + + final int newOrientation = isLandscape + ? ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + : ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE; + + activity.setRequestedOrientation(newOrientation); + } + + /* + * Will scroll down to description view after long click on moreOptionsButton + * */ + @Override + public void onMoreOptionsLongClicked() { + final CoordinatorLayout.LayoutParams params = + (CoordinatorLayout.LayoutParams) binding.appBarLayout.getLayoutParams(); + final AppBarLayout.Behavior behavior = (AppBarLayout.Behavior) params.getBehavior(); + final ValueAnimator valueAnimator = ValueAnimator + .ofInt(0, -binding.playerPlaceholder.getHeight()); + valueAnimator.setInterpolator(new DecelerateInterpolator()); + valueAnimator.addUpdateListener(animation -> { + behavior.setTopAndBottomOffset((int) animation.getAnimatedValue()); + binding.appBarLayout.requestLayout(); + }); + valueAnimator.setInterpolator(new DecelerateInterpolator()); + valueAnimator.setDuration(500); + valueAnimator.start(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Player related utils + //////////////////////////////////////////////////////////////////////////*/ + + private void showSystemUi() { + if (DEBUG) { + Log.d(TAG, "showSystemUi() called"); + } + + if (activity == null) { + return; + } + + // Prevent jumping of the player on devices with cutout + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + activity.getWindow().getAttributes().layoutInDisplayCutoutMode = + WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT; + } + activity.getWindow().getDecorView().setSystemUiVisibility(0); + activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + activity.getWindow().setStatusBarColor(ThemeHelper.resolveColorFromAttr( + requireContext(), android.R.attr.colorPrimary)); + } + } + + private void hideSystemUi() { + if (DEBUG) { + Log.d(TAG, "hideSystemUi() called"); + } + + if (activity == null) { + return; + } + + // Prevent jumping of the player on devices with cutout + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + activity.getWindow().getAttributes().layoutInDisplayCutoutMode = + WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES; + } + int visibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE + | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY; + + // In multiWindow mode status bar is not transparent for devices with cutout + // if I include this flag. So without it is better in this case + final boolean isInMultiWindow = DeviceUtils.isInMultiWindow(activity); + if (!isInMultiWindow) { + visibility |= View.SYSTEM_UI_FLAG_FULLSCREEN; + } + activity.getWindow().getDecorView().setSystemUiVisibility(visibility); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP + && (isInMultiWindow || isFullscreen())) { + activity.getWindow().setStatusBarColor(Color.TRANSPARENT); + activity.getWindow().setNavigationBarColor(Color.TRANSPARENT); + } + activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + } + + // Listener implementation + @Override + public void hideSystemUiIfNeeded() { + if (isFullscreen() + && bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED) { + hideSystemUi(); + } + } + + private boolean isFullscreen() { + return isPlayerAvailable() && player.UIs().get(VideoPlayerUi.class) + .map(VideoPlayerUi::isFullscreen).orElse(false); + } + + private boolean playerIsNotStopped() { + return isPlayerAvailable() && !player.isStopped(); + } + + private void restoreDefaultBrightness() { + final WindowManager.LayoutParams lp = activity.getWindow().getAttributes(); + if (lp.screenBrightness == -1) { + return; + } + + // Restore the old brightness when fragment.onPause() called or + // when a player is in portrait + lp.screenBrightness = -1; + activity.getWindow().setAttributes(lp); + } + + private void setupBrightness() { + if (activity == null) { + return; + } + + final WindowManager.LayoutParams lp = activity.getWindow().getAttributes(); + if (!isFullscreen() || bottomSheetState != BottomSheetBehavior.STATE_EXPANDED) { + // Apply system brightness when the player is not in fullscreen + restoreDefaultBrightness(); + } else { + // Do not restore if user has disabled brightness gesture + if (!PlayerHelper.getActionForRightGestureSide(activity) + .equals(getString(R.string.brightness_control_key)) + && !PlayerHelper.getActionForLeftGestureSide(activity) + .equals(getString(R.string.brightness_control_key))) { + return; + } + // Restore already saved brightness level + final float brightnessLevel = PlayerHelper.getScreenBrightness(activity); + if (brightnessLevel == lp.screenBrightness) { + return; + } + lp.screenBrightness = brightnessLevel; + activity.getWindow().setAttributes(lp); + } + } + + /** + * Make changes to the UI to accommodate for better usability on bigger screens such as TVs + * or in Android's desktop mode (DeX etc). + */ + private void accommodateForTvAndDesktopMode() { + if (DeviceUtils.isTv(getContext())) { + // remove ripple effects from detail controls + final int transparent = ContextCompat.getColor(requireContext(), + R.color.transparent_background_color); + binding.detailControlsPlaylistAppend.setBackgroundColor(transparent); + binding.detailControlsBackground.setBackgroundColor(transparent); + binding.detailControlsPopup.setBackgroundColor(transparent); + binding.detailControlsDownload.setBackgroundColor(transparent); + binding.detailControlsShare.setBackgroundColor(transparent); + binding.detailControlsOpenInBrowser.setBackgroundColor(transparent); + binding.detailControlsPlayWithKodi.setBackgroundColor(transparent); + } + if (DeviceUtils.isDesktopMode(getContext())) { + // Remove the "hover" overlay (since it is visible on all mouse events and interferes + // with the video content being played) + binding.detailThumbnailRootLayout.setForeground(null); + } + } + + private void checkLandscape() { + if ((!player.isPlaying() && player.getPlayQueue() != playQueue) + || player.getPlayQueue() == null) { + setAutoPlay(true); + } + + player.UIs().get(MainPlayerUi.class).ifPresent(MainPlayerUi::checkLandscape); + // Let's give a user time to look at video information page if video is not playing + if (globalScreenOrientationLocked(activity) && !player.isPlaying()) { + player.play(); + } + } + + /* + * Means that the player fragment was swiped away via BottomSheetLayout + * and is empty but ready for any new actions. See cleanUp() + * */ + private boolean wasCleared() { + return url == null; + } + + @Nullable + private StackItem findQueueInStack(final PlayQueue queue) { + StackItem item = null; + final Iterator iterator = stack.descendingIterator(); + while (iterator.hasNext()) { + final StackItem next = iterator.next(); + if (next.getPlayQueue().equalStreams(queue)) { + item = next; + break; + } + } + return item; + } + + private void replaceQueueIfUserConfirms(final Runnable onAllow) { + @Nullable final PlayQueue activeQueue = isPlayerAvailable() ? player.getPlayQueue() : null; + + // Player will have STATE_IDLE when a user pressed back button + if (isClearingQueueConfirmationRequired(activity) + && playerIsNotStopped() + && activeQueue != null + && !activeQueue.equalStreams(playQueue)) { + showClearingQueueConfirmation(onAllow); + } else { + onAllow.run(); + } + } + + private void showClearingQueueConfirmation(final Runnable onAllow) { + new AlertDialog.Builder(activity) + .setTitle(R.string.clear_queue_confirmation_description) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.ok, (dialog, which) -> { + onAllow.run(); + dialog.dismiss(); + }) + .show(); + } + + private void showExternalVideoPlaybackDialog() { + if (currentInfo == null) { + return; + } + + final AlertDialog.Builder builder = new AlertDialog.Builder(activity); + builder.setTitle(R.string.select_quality_external_players); + builder.setNeutralButton(R.string.open_in_browser, (dialog, i) -> + ShareUtils.openUrlInBrowser(requireActivity(), url)); + + final List videoStreamsForExternalPlayers = + ListHelper.getSortedStreamVideosList( + activity, + getUrlAndNonTorrentStreams(currentInfo.getVideoStreams()), + getUrlAndNonTorrentStreams(currentInfo.getVideoOnlyStreams()), + false, + false + ); + + if (videoStreamsForExternalPlayers.isEmpty()) { + builder.setMessage(R.string.no_video_streams_available_for_external_players); + builder.setPositiveButton(R.string.ok, null); + + } else { + final int selectedVideoStreamIndexForExternalPlayers = + ListHelper.getDefaultResolutionIndex(activity, videoStreamsForExternalPlayers); + final CharSequence[] resolutions = videoStreamsForExternalPlayers.stream() + .map(VideoStream::getResolution).toArray(CharSequence[]::new); + + builder.setSingleChoiceItems(resolutions, selectedVideoStreamIndexForExternalPlayers, + null); + builder.setNegativeButton(R.string.cancel, null); + builder.setPositiveButton(R.string.ok, (dialog, i) -> { + final int index = ((AlertDialog) dialog).getListView().getCheckedItemPosition(); + // We don't have to manage the index validity because if there is no stream + // available for external players, this code will be not executed and if there is + // no stream which matches the default resolution, 0 is returned by + // ListHelper.getDefaultResolutionIndex. + // The index cannot be outside the bounds of the list as its always between 0 and + // the list size - 1, . + startOnExternalPlayer(activity, currentInfo, + videoStreamsForExternalPlayers.get(index)); + }); + } + builder.show(); + } + + private void showExternalAudioPlaybackDialog() { + if (currentInfo == null) { + return; + } + + final List audioStreams = getUrlAndNonTorrentStreams( + currentInfo.getAudioStreams()); + final List audioTracks = + ListHelper.getFilteredAudioStreams(activity, audioStreams); + + if (audioTracks.isEmpty()) { + Toast.makeText(activity, R.string.no_audio_streams_available_for_external_players, + Toast.LENGTH_SHORT).show(); + } else if (audioTracks.size() == 1) { + startOnExternalPlayer(activity, currentInfo, audioTracks.get(0)); + } else { + final int selectedAudioStream = + ListHelper.getDefaultAudioFormat(activity, audioTracks); + final CharSequence[] trackNames = audioTracks.stream() + .map(audioStream -> Localization.audioTrackName(activity, audioStream)) + .toArray(CharSequence[]::new); + + new AlertDialog.Builder(activity) + .setTitle(R.string.select_audio_track_external_players) + .setNeutralButton(R.string.open_in_browser, (dialog, i) -> + ShareUtils.openUrlInBrowser(requireActivity(), url)) + .setSingleChoiceItems(trackNames, selectedAudioStream, null) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.ok, (dialog, i) -> { + final int index = ((AlertDialog) dialog).getListView() + .getCheckedItemPosition(); + startOnExternalPlayer(activity, currentInfo, audioTracks.get(index)); + }) + .show(); + } + } + + /* + * Remove unneeded information while waiting for a next task + * */ + private void cleanUp() { + // New beginning + stack.clear(); + if (currentWorker != null) { + currentWorker.dispose(); + } + playerHolder.stopService(); + setInitialData(0, null, "", null); + currentInfo = null; + updateOverlayData(null, null, List.of()); + } + + /*////////////////////////////////////////////////////////////////////////// + // Bottom mini player + //////////////////////////////////////////////////////////////////////////*/ + + /** + * That's for Android TV support. Move focus from main fragment to the player or back + * based on what is currently selected + * + * @param toMain if true than the main fragment will be focused or the player otherwise + */ + private void moveFocusToMainFragment(final boolean toMain) { + setupBrightness(); + final ViewGroup mainFragment = requireActivity().findViewById(R.id.fragment_holder); + // Hamburger button steels a focus even under bottomSheet + final Toolbar toolbar = requireActivity().findViewById(R.id.toolbar); + final int afterDescendants = ViewGroup.FOCUS_AFTER_DESCENDANTS; + final int blockDescendants = ViewGroup.FOCUS_BLOCK_DESCENDANTS; + if (toMain) { + mainFragment.setDescendantFocusability(afterDescendants); + toolbar.setDescendantFocusability(afterDescendants); + ((ViewGroup) requireView()).setDescendantFocusability(blockDescendants); + // Only focus the mainFragment if the mainFragment (e.g. search-results) + // or the toolbar (e.g. Textfield for search) don't have focus. + // This was done to fix problems with the keyboard input, see also #7490 + if (!mainFragment.hasFocus() && !toolbar.hasFocus()) { + mainFragment.requestFocus(); + } + } else { + mainFragment.setDescendantFocusability(blockDescendants); + toolbar.setDescendantFocusability(blockDescendants); + ((ViewGroup) requireView()).setDescendantFocusability(afterDescendants); + // Only focus the player if it not already has focus + if (!binding.getRoot().hasFocus()) { + binding.detailThumbnailRootLayout.requestFocus(); + } + } + } + + /** + * When the mini player exists the view underneath it is not touchable. + * Bottom padding should be equal to the mini player's height in this case + * + * @param showMore whether main fragment should be expanded or not + */ + private void manageSpaceAtTheBottom(final boolean showMore) { + final int peekHeight = getResources().getDimensionPixelSize(R.dimen.mini_player_height); + final ViewGroup holder = requireActivity().findViewById(R.id.fragment_holder); + final int newBottomPadding; + if (showMore) { + newBottomPadding = 0; + } else { + newBottomPadding = peekHeight; + } + if (holder.getPaddingBottom() == newBottomPadding) { + return; + } + holder.setPadding(holder.getPaddingLeft(), + holder.getPaddingTop(), + holder.getPaddingRight(), + newBottomPadding); + } + + private void setupBottomPlayer() { + final CoordinatorLayout.LayoutParams params = + (CoordinatorLayout.LayoutParams) binding.appBarLayout.getLayoutParams(); + final AppBarLayout.Behavior behavior = (AppBarLayout.Behavior) params.getBehavior(); + + final FrameLayout bottomSheetLayout = activity.findViewById(R.id.fragment_player_holder); + bottomSheetBehavior = BottomSheetBehavior.from(bottomSheetLayout); + bottomSheetBehavior.setState(lastStableBottomSheetState); + updateBottomSheetState(lastStableBottomSheetState); + + final int peekHeight = getResources().getDimensionPixelSize(R.dimen.mini_player_height); + if (bottomSheetState != BottomSheetBehavior.STATE_HIDDEN) { + manageSpaceAtTheBottom(false); + bottomSheetBehavior.setPeekHeight(peekHeight); + if (bottomSheetState == BottomSheetBehavior.STATE_COLLAPSED) { + binding.overlayLayout.setAlpha(MAX_OVERLAY_ALPHA); + } else if (bottomSheetState == BottomSheetBehavior.STATE_EXPANDED) { + binding.overlayLayout.setAlpha(0); + setOverlayElementsClickable(false); + } + } + + bottomSheetCallback = new BottomSheetBehavior.BottomSheetCallback() { + @Override + public void onStateChanged(@NonNull final View bottomSheet, final int newState) { + updateBottomSheetState(newState); + + switch (newState) { + case BottomSheetBehavior.STATE_HIDDEN: + moveFocusToMainFragment(true); + manageSpaceAtTheBottom(true); + + bottomSheetBehavior.setPeekHeight(0); + cleanUp(); + break; + case BottomSheetBehavior.STATE_EXPANDED: + moveFocusToMainFragment(false); + manageSpaceAtTheBottom(false); + + bottomSheetBehavior.setPeekHeight(peekHeight); + // Disable click because overlay buttons located on top of buttons + // from the player + setOverlayElementsClickable(false); + hideSystemUiIfNeeded(); + // Conditions when the player should be expanded to fullscreen + if (DeviceUtils.isLandscape(requireContext()) + && isPlayerAvailable() + && player.isPlaying() + && !isFullscreen() + && !DeviceUtils.isTablet(activity)) { + player.UIs().get(MainPlayerUi.class) + .ifPresent(MainPlayerUi::toggleFullscreen); + } + setOverlayLook(binding.appBarLayout, behavior, 1); + break; + case BottomSheetBehavior.STATE_COLLAPSED: + moveFocusToMainFragment(true); + manageSpaceAtTheBottom(false); + + bottomSheetBehavior.setPeekHeight(peekHeight); + + // Re-enable clicks + setOverlayElementsClickable(true); + if (isPlayerAvailable()) { + player.UIs().get(MainPlayerUi.class) + .ifPresent(MainPlayerUi::closeItemsList); + } + setOverlayLook(binding.appBarLayout, behavior, 0); + break; + case BottomSheetBehavior.STATE_DRAGGING: + case BottomSheetBehavior.STATE_SETTLING: + if (isFullscreen()) { + showSystemUi(); + } + if (isPlayerAvailable()) { + player.UIs().get(MainPlayerUi.class).ifPresent(ui -> { + if (ui.isControlsVisible()) { + ui.hideControls(0, 0); + } + }); + } + break; + case BottomSheetBehavior.STATE_HALF_EXPANDED: + break; + } + } + + @Override + public void onSlide(@NonNull final View bottomSheet, final float slideOffset) { + setOverlayLook(binding.appBarLayout, behavior, slideOffset); + } + }; + + bottomSheetBehavior.addBottomSheetCallback(bottomSheetCallback); + + // User opened a new page and the player will hide itself + activity.getSupportFragmentManager().addOnBackStackChangedListener(() -> { + if (bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED) { + bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); + } + }); + } + + private void updateOverlayPlayQueueButtonVisibility() { + final boolean isPlayQueueEmpty = + player == null // no player => no play queue :) + || player.getPlayQueue() == null + || player.getPlayQueue().isEmpty(); + if (binding != null) { + // binding is null when rotating the device... + binding.overlayPlayQueueButton.setVisibility( + isPlayQueueEmpty ? View.GONE : View.VISIBLE); + } + } + + private void updateOverlayData(@Nullable final String overlayTitle, + @Nullable final String uploader, + @NonNull final List thumbnails) { + binding.overlayTitleTextView.setText(isEmpty(overlayTitle) ? "" : overlayTitle); + binding.overlayChannelTextView.setText(isEmpty(uploader) ? "" : uploader); + binding.overlayThumbnail.setImageDrawable(null); + PicassoHelper.loadDetailsThumbnail(thumbnails).tag(PICASSO_VIDEO_DETAILS_TAG) + .into(binding.overlayThumbnail); + } + + private void setOverlayPlayPauseImage(final boolean playerIsPlaying) { + final int drawable = playerIsPlaying + ? R.drawable.ic_pause + : R.drawable.ic_play_arrow; + binding.overlayPlayPauseButton.setImageResource(drawable); + } + + private void setOverlayLook(final AppBarLayout appBar, + final AppBarLayout.Behavior behavior, + final float slideOffset) { + // SlideOffset < 0 when mini player is about to close via swipe. + // Stop animation in this case + if (behavior == null || slideOffset < 0) { + return; + } + binding.overlayLayout.setAlpha(Math.min(MAX_OVERLAY_ALPHA, 1 - slideOffset)); + // These numbers are not special. They just do a cool transition + behavior.setTopAndBottomOffset( + (int) (-binding.detailThumbnailImageView.getHeight() * 2 * (1 - slideOffset) / 3)); + appBar.requestLayout(); + } + + private void setOverlayElementsClickable(final boolean enable) { + binding.overlayThumbnail.setClickable(enable); + binding.overlayThumbnail.setLongClickable(enable); + binding.overlayMetadataLayout.setClickable(enable); + binding.overlayMetadataLayout.setLongClickable(enable); + binding.overlayButtonsLayout.setClickable(enable); + binding.overlayPlayQueueButton.setClickable(enable); + binding.overlayPlayPauseButton.setClickable(enable); + binding.overlayCloseButton.setClickable(enable); + } + + // helpers to check the state of player and playerService + boolean isPlayerAvailable() { + return player != null; + } + + boolean isPlayerServiceAvailable() { + return playerService != null; + } + + boolean isPlayerAndPlayerServiceAvailable() { + return player != null && playerService != null; + } + + public Optional getRoot() { + return Optional.ofNullable(player) + .flatMap(player1 -> player1.UIs().get(VideoPlayerUi.class)) + .map(playerUi -> playerUi.getBinding().getRoot()); + } + + private void updateBottomSheetState(final int newState) { + bottomSheetState = newState; + if (newState != BottomSheetBehavior.STATE_DRAGGING + && newState != BottomSheetBehavior.STATE_SETTLING) { + lastStableBottomSheetState = newState; + } + } +} diff --git a/app/src/braveLegacy/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java b/app/src/braveLegacy/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java new file mode 100644 index 0000000000..483871345c --- /dev/null +++ b/app/src/braveLegacy/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java @@ -0,0 +1,1135 @@ +package org.schabi.newpipe.fragments.list.search; + +import static androidx.recyclerview.widget.ItemTouchHelper.Callback.makeMovementFlags; +import static org.schabi.newpipe.extractor.utils.Utils.isBlank; +import static org.schabi.newpipe.ktx.ViewUtils.animate; +import static org.schabi.newpipe.util.ExtractorHelper.showMetaInfoInTextView; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.text.Editable; +import android.text.Html; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.text.style.CharacterStyle; +import android.util.Log; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.DecelerateInterpolator; +import android.view.inputmethod.EditorInfo; +import android.widget.EditText; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.widget.TooltipCompat; +import androidx.core.text.HtmlCompat; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.FragmentManager; +import androidx.lifecycle.ViewModelProvider; +import androidx.preference.PreferenceManager; +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.RecyclerView; + +import org.schabi.newpipe.App; +import org.schabi.newpipe.R; +import org.schabi.newpipe.databinding.FragmentSearchBinding; +import org.schabi.newpipe.error.ErrorInfo; +import org.schabi.newpipe.error.ReCaptchaActivity; +import org.schabi.newpipe.error.UserAction; +import org.schabi.newpipe.extractor.InfoItem; +import org.schabi.newpipe.extractor.ListExtractor; +import org.schabi.newpipe.extractor.MetaInfo; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.extractor.Page; +import org.schabi.newpipe.extractor.StreamingService; +import org.schabi.newpipe.extractor.search.SearchExtractor; +import org.schabi.newpipe.extractor.search.SearchInfo; +import org.schabi.newpipe.extractor.search.filter.FilterItem; +import org.schabi.newpipe.fragments.BackPressable; +import org.schabi.newpipe.fragments.list.BaseListFragment; +import org.schabi.newpipe.fragments.list.search.filter.SearchFilterChipDialogFragment; +import org.schabi.newpipe.fragments.list.search.filter.SearchFilterDialogFragment; +import org.schabi.newpipe.fragments.list.search.filter.SearchFilterLogic; +import org.schabi.newpipe.fragments.list.search.filter.SearchFilterOptionMenuAlikeDialogFragment; +import org.schabi.newpipe.ktx.AnimationType; +import org.schabi.newpipe.ktx.ExceptionUtils; +import org.schabi.newpipe.local.history.HistoryRecordManager; +import org.schabi.newpipe.settings.NewPipeSettings; +import org.schabi.newpipe.util.Constants; +import org.schabi.newpipe.util.DeviceUtils; +import org.schabi.newpipe.util.ExtractorHelper; +import org.schabi.newpipe.util.KeyboardUtil; +import org.schabi.newpipe.util.NavigationHelper; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Queue; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import icepick.State; +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; +import io.reactivex.rxjava3.core.Observable; +import io.reactivex.rxjava3.core.Single; +import io.reactivex.rxjava3.disposables.CompositeDisposable; +import io.reactivex.rxjava3.disposables.Disposable; +import io.reactivex.rxjava3.schedulers.Schedulers; +import io.reactivex.rxjava3.subjects.PublishSubject; + +public class SearchFragment extends BaseListFragment> + implements BackPressable { + /*////////////////////////////////////////////////////////////////////////// + // Search + //////////////////////////////////////////////////////////////////////////*/ + + /** + * The suggestions will only be fetched from network if the query meet this threshold (>=). + * (local ones will be fetched regardless of the length) + */ + private static final int THRESHOLD_NETWORK_SUGGESTION = 1; + + /** + * How much time have to pass without emitting a item (i.e. the user stop typing) + * to fetch/show the suggestions, in milliseconds. + */ + private static final int SUGGESTIONS_DEBOUNCE = 120; //ms + private final PublishSubject suggestionPublisher = PublishSubject.create(); + + @State + protected int serviceId = Constants.NO_SERVICE_ID; + + // these three represents the current search query + @State + String searchString; + + List selectedContentFilter = new ArrayList<>(); + + List selectedSortFilter = new ArrayList<>(); + + // these represents the last search + @State + String lastSearchedString; + + @State + String searchSuggestion; + + @State + boolean isCorrectedSearch; + + @State + MetaInfo[] metaInfo; + + @State + boolean wasSearchFocused = false; + + private Page nextPage; + private boolean showLocalSuggestions = true; + private boolean showRemoteSuggestions = true; + + private Disposable searchDisposable; + private Disposable suggestionDisposable; + private final CompositeDisposable disposables = new CompositeDisposable(); + + private SuggestionListAdapter suggestionListAdapter; + private HistoryRecordManager historyRecordManager; + + /*////////////////////////////////////////////////////////////////////////// + // Views + //////////////////////////////////////////////////////////////////////////*/ + + private FragmentSearchBinding searchBinding; + + protected View searchToolbarContainer; + private EditText searchEditText; + private View searchClear; + + private boolean suggestionsPanelVisible = false; + + /*////////////////////////////////////////////////////////////////////////*/ + + /** + * TextWatcher to remove rich-text formatting on the search EditText when pasting content + * from the clipboard. + */ + private TextWatcher textWatcher; + + @State + ArrayList userSelectedContentFilterList; + + @State + ArrayList userSelectedSortFilterList = null; + + protected SearchViewModel searchViewModel; + protected SearchFilterLogic.Factory.Variant logicVariant = + SearchFilterLogic.Factory.Variant.SEARCH_FILTER_LOGIC_DEFAULT; + + + public static SearchFragment getInstance(final int serviceId, final String searchString) { + final SearchFragment searchFragment; + final App app = App.getApp(); + + + final String searchUi = PreferenceManager.getDefaultSharedPreferences(app) + .getString(app.getString(R.string.search_filter_ui_key), + app.getString(R.string.search_filter_ui_value)); + if (app.getString(R.string.search_filter_ui_option_menu_legacy_key).equals(searchUi)) { + searchFragment = new SearchFragmentLegacy(); + } else { + searchFragment = new SearchFragment(); + } + + searchFragment.setQuery(serviceId, searchString); + + if (!TextUtils.isEmpty(searchString)) { + searchFragment.setSearchOnResume(); + } + + return searchFragment; + } + + /** + * Set wasLoading to true so when the fragment onResume is called, the initial search is done. + */ + private void setSearchOnResume() { + wasLoading.set(true); + } + + /*////////////////////////////////////////////////////////////////////////// + // Fragment's LifeCycle + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onAttach(@NonNull final Context context) { + super.onAttach(context); + + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(activity); + showLocalSuggestions = NewPipeSettings.showLocalSearchSuggestions(activity, prefs); + showRemoteSuggestions = NewPipeSettings.showRemoteSearchSuggestions(activity, prefs); + + suggestionListAdapter = new SuggestionListAdapter(); + historyRecordManager = new HistoryRecordManager(context); + } + + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, + @Nullable final ViewGroup container, + @Nullable final Bundle savedInstanceState) { + + if (userSelectedContentFilterList == null) { + userSelectedContentFilterList = new ArrayList<>(); + } + + if (userSelectedSortFilterList == null) { + userSelectedSortFilterList = new ArrayList<>(); + } + + initViewModel(); + + // observe the content/sort filter items lists + searchViewModel.getSelectedContentFilterItemListLiveData().observe( + getViewLifecycleOwner(), filterItems -> selectedContentFilter = filterItems); + searchViewModel.getSelectedSortFilterItemListLiveData().observe( + getViewLifecycleOwner(), filterItems -> selectedSortFilter = filterItems); + + // the content/sort filters ids lists are only + // observed here to store them via Icepick + searchViewModel.getUserSelectedContentFilterListLiveData().observe( + getViewLifecycleOwner(), filterIds -> userSelectedContentFilterList = filterIds); + searchViewModel.getUserSelectedSortFilterListLiveData().observe( + getViewLifecycleOwner(), filterIds -> userSelectedSortFilterList = filterIds); + + searchViewModel.getDoSearchLiveData().observe( + getViewLifecycleOwner(), doSearch -> { + if (doSearch) { + selectedFilters(selectedContentFilter, selectedSortFilter); + searchViewModel.weConsumedDoSearchLiveData(); + } + }); + + return inflater.inflate(R.layout.fragment_search, container, false); + } + + protected void initViewModel() { + searchViewModel = new ViewModelProvider(this, SearchViewModel.Companion + .getFactory(serviceId, + logicVariant, + userSelectedContentFilterList, + userSelectedSortFilterList)) + .get(SearchViewModel.class); + } + + @Override + public void onViewCreated(@NonNull final View rootView, final Bundle savedInstanceState) { + searchBinding = FragmentSearchBinding.bind(rootView); + super.onViewCreated(rootView, savedInstanceState); + showSearchOnStart(); + initSearchListeners(); + } + + @Override + public void onStart() { + if (DEBUG) { + Log.d(TAG, "onStart() called"); + } + super.onStart(); + } + + @Override + public void onPause() { + super.onPause(); + + wasSearchFocused = searchEditText.hasFocus(); + + if (searchDisposable != null) { + searchDisposable.dispose(); + } + if (suggestionDisposable != null) { + suggestionDisposable.dispose(); + } + disposables.clear(); + hideKeyboardSearch(); + } + + @Override + public void onResume() { + if (DEBUG) { + Log.d(TAG, "onResume() called"); + } + super.onResume(); + + if (suggestionDisposable == null || suggestionDisposable.isDisposed()) { + initSuggestionObserver(); + } + + if (!TextUtils.isEmpty(searchString)) { + if (wasLoading.getAndSet(false)) { + search(searchString); + return; + } else if (infoListAdapter.getItemsList().isEmpty()) { + if (savedState == null) { + search(searchString); + return; + } else if (!isLoading.get() && !wasSearchFocused && lastPanelError == null) { + infoListAdapter.clearStreamItemList(); + showEmptyState(); + } + } + } + + handleSearchSuggestion(); + + showMetaInfoInTextView(metaInfo == null ? null : Arrays.asList(metaInfo), + searchBinding.searchMetaInfoTextView, searchBinding.searchMetaInfoSeparator, + disposables); + + if (TextUtils.isEmpty(searchString) || wasSearchFocused) { + showKeyboardSearch(); + showSuggestionsPanel(); + } else { + hideKeyboardSearch(); + hideSuggestionsPanel(); + } + wasSearchFocused = false; + } + + @Override + public void onDestroyView() { + if (DEBUG) { + Log.d(TAG, "onDestroyView() called"); + } + unsetSearchListeners(); + + searchBinding = null; + super.onDestroyView(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (searchDisposable != null) { + searchDisposable.dispose(); + } + if (suggestionDisposable != null) { + suggestionDisposable.dispose(); + } + disposables.clear(); + } + + @Override + public void onActivityResult(final int requestCode, final int resultCode, final Intent data) { + if (requestCode == ReCaptchaActivity.RECAPTCHA_REQUEST) { + if (resultCode == Activity.RESULT_OK + && !TextUtils.isEmpty(searchString)) { + search(searchString); + } else { + Log.e(TAG, "ReCaptcha failed"); + } + } else { + Log.e(TAG, "Request code from activity not supported [" + requestCode + "]"); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Init + //////////////////////////////////////////////////////////////////////////*/ + + @Override + protected void initViews(final View rootView, final Bundle savedInstanceState) { + super.initViews(rootView, savedInstanceState); + + searchBinding.suggestionsList.setAdapter(suggestionListAdapter); + // animations are just strange and useless, since the suggestions keep changing too much + searchBinding.suggestionsList.setItemAnimator(null); + new ItemTouchHelper(new ItemTouchHelper.Callback() { + @Override + public int getMovementFlags(@NonNull final RecyclerView recyclerView, + @NonNull final RecyclerView.ViewHolder viewHolder) { + return getSuggestionMovementFlags(viewHolder); + } + + @Override + public boolean onMove(@NonNull final RecyclerView recyclerView, + @NonNull final RecyclerView.ViewHolder viewHolder, + @NonNull final RecyclerView.ViewHolder viewHolder1) { + return false; + } + + @Override + public void onSwiped(@NonNull final RecyclerView.ViewHolder viewHolder, final int i) { + onSuggestionItemSwiped(viewHolder); + } + }).attachToRecyclerView(searchBinding.suggestionsList); + + searchToolbarContainer = activity.findViewById(R.id.toolbar_search_container); + searchEditText = searchToolbarContainer.findViewById(R.id.toolbar_search_edit_text); + searchClear = searchToolbarContainer.findViewById(R.id.toolbar_search_clear); + } + + /*////////////////////////////////////////////////////////////////////////// + // State Saving + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void writeTo(final Queue objectsToSave) { + super.writeTo(objectsToSave); + objectsToSave.add(nextPage); + } + + @Override + public void readFrom(@NonNull final Queue savedObjects) throws Exception { + super.readFrom(savedObjects); + nextPage = (Page) savedObjects.poll(); + } + + @Override + public void onSaveInstanceState(@NonNull final Bundle bundle) { + searchString = searchEditText != null + ? getSearchEditString().trim() + : searchString; + + super.onSaveInstanceState(bundle); + } + + /*////////////////////////////////////////////////////////////////////////// + // Init's + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void reloadContent() { + if (!TextUtils.isEmpty(searchString) || (searchEditText != null + && !isSearchEditBlank())) { + search(!TextUtils.isEmpty(searchString) + ? searchString + : getSearchEditString()); + } else { + if (searchEditText != null) { + searchEditText.setText(""); + showKeyboardSearch(); + } + hideErrorPanel(); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Menu + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onCreateOptionsMenu(@NonNull final Menu menu, + @NonNull final MenuInflater inflater) { + super.onCreateOptionsMenu(menu, inflater); + + final ActionBar supportActionBar = activity.getSupportActionBar(); + if (supportActionBar != null) { + supportActionBar.setDisplayShowTitleEnabled(false); + supportActionBar.setDisplayHomeAsUpEnabled(true); + } + + createMenu(menu, inflater); + } + + protected void createMenu(@NonNull final Menu menu, + @NonNull final MenuInflater inflater) { + inflater.inflate(R.menu.menu_search_fragment, menu); + } + + @Override + public boolean onOptionsItemSelected(@NonNull final MenuItem item) { + if (item.getItemId() == R.id.action_filter) { + hideKeyboardSearch(); + showSelectFiltersDialog(); + return false; + } + return true; + } + + /*////////////////////////////////////////////////////////////////////////// + // Search + //////////////////////////////////////////////////////////////////////////*/ + + private void showSearchOnStart() { + if (DEBUG) { + Log.d(TAG, "showSearchOnStart() called, searchQuery → " + + searchString + + ", lastSearchedQuery → " + + lastSearchedString); + } + searchEditText.setText(searchString); + if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.LOLLIPOP) { + searchEditText.setHintTextColor(searchEditText.getTextColors().withAlpha(128)); + } + + if (TextUtils.isEmpty(searchString) + || isSearchEditBlank()) { + searchToolbarContainer.setTranslationX(100); + searchToolbarContainer.setAlpha(0.0f); + searchToolbarContainer.setVisibility(View.VISIBLE); + searchToolbarContainer.animate() + .translationX(0) + .alpha(1.0f) + .setDuration(200) + .setInterpolator(new DecelerateInterpolator()).start(); + } else { + searchToolbarContainer.setTranslationX(0); + searchToolbarContainer.setAlpha(1.0f); + searchToolbarContainer.setVisibility(View.VISIBLE); + } + } + + private void initSearchListeners() { + if (DEBUG) { + Log.d(TAG, "initSearchListeners() called"); + } + searchClear.setOnClickListener(v -> { + if (DEBUG) { + Log.d(TAG, "onClick() called with: v = [" + v + "]"); + } + if (isSearchEditBlank()) { + NavigationHelper.gotoMainFragment(getFM()); + return; + } + + searchBinding.correctSuggestion.setVisibility(View.GONE); + + searchEditText.setText(""); + suggestionListAdapter.submitList(null); + showKeyboardSearch(); + }); + + TooltipCompat.setTooltipText(searchClear, getString(R.string.clear)); + + searchEditText.setOnClickListener(v -> { + if (DEBUG) { + Log.d(TAG, "onClick() called with: v = [" + v + "]"); + } + if ((showLocalSuggestions || showRemoteSuggestions) && !isErrorPanelVisible()) { + showSuggestionsPanel(); + } + if (DeviceUtils.isTv(getContext())) { + showKeyboardSearch(); + } + }); + + searchEditText.setOnFocusChangeListener((View v, boolean hasFocus) -> { + if (DEBUG) { + Log.d(TAG, "onFocusChange() called with: " + + "v = [" + v + "], hasFocus = [" + hasFocus + "]"); + } + if ((showLocalSuggestions || showRemoteSuggestions) + && hasFocus && !isErrorPanelVisible()) { + showSuggestionsPanel(); + } + }); + + suggestionListAdapter.setListener(new SuggestionListAdapter.OnSuggestionItemSelected() { + @Override + public void onSuggestionItemSelected(final SuggestionItem item) { + search(item.query); + searchEditText.setText(item.query); + } + + @Override + public void onSuggestionItemInserted(final SuggestionItem item) { + searchEditText.setText(item.query); + searchEditText.setSelection(searchEditText.getText().length()); + } + + @Override + public void onSuggestionItemLongClick(final SuggestionItem item) { + if (item.fromHistory) { + showDeleteSuggestionDialog(item); + } + } + }); + + if (textWatcher != null) { + searchEditText.removeTextChangedListener(textWatcher); + } + textWatcher = new TextWatcher() { + @Override + public void beforeTextChanged(final CharSequence s, final int start, + final int count, final int after) { + // Do nothing, old text is already clean + } + + @Override + public void onTextChanged(final CharSequence s, final int start, + final int before, final int count) { + // Changes are handled in afterTextChanged; CharSequence cannot be changed here. + } + + @Override + public void afterTextChanged(final Editable s) { + // Remove rich text formatting + for (final CharacterStyle span : s.getSpans(0, s.length(), CharacterStyle.class)) { + s.removeSpan(span); + } + + final String newText = getSearchEditString().trim(); + suggestionPublisher.onNext(newText); + } + }; + searchEditText.addTextChangedListener(textWatcher); + searchEditText.setOnEditorActionListener( + (TextView v, int actionId, KeyEvent event) -> { + if (DEBUG) { + Log.d(TAG, "onEditorAction() called with: v = [" + v + "], " + + "actionId = [" + actionId + "], event = [" + event + "]"); + } + if (actionId == EditorInfo.IME_ACTION_PREVIOUS) { + hideKeyboardSearch(); + } else if (event != null + && (event.getKeyCode() == KeyEvent.KEYCODE_ENTER + || event.getAction() == EditorInfo.IME_ACTION_SEARCH)) { + searchEditText.setText(getSearchEditString().trim()); + search(getSearchEditString()); + return true; + } + return false; + }); + + if (suggestionDisposable == null || suggestionDisposable.isDisposed()) { + initSuggestionObserver(); + } + } + + private void unsetSearchListeners() { + if (DEBUG) { + Log.d(TAG, "unsetSearchListeners() called"); + } + searchClear.setOnClickListener(null); + searchClear.setOnLongClickListener(null); + searchEditText.setOnClickListener(null); + searchEditText.setOnFocusChangeListener(null); + searchEditText.setOnEditorActionListener(null); + + if (textWatcher != null) { + searchEditText.removeTextChangedListener(textWatcher); + } + textWatcher = null; + } + + private void showSuggestionsPanel() { + if (DEBUG) { + Log.d(TAG, "showSuggestionsPanel() called"); + } + suggestionsPanelVisible = true; + animate(searchBinding.suggestionsPanel, true, 200, + AnimationType.LIGHT_SLIDE_AND_ALPHA); + } + + private void hideSuggestionsPanel() { + if (DEBUG) { + Log.d(TAG, "hideSuggestionsPanel() called"); + } + suggestionsPanelVisible = false; + animate(searchBinding.suggestionsPanel, false, 200, + AnimationType.LIGHT_SLIDE_AND_ALPHA); + } + + private void showKeyboardSearch() { + if (DEBUG) { + Log.d(TAG, "showKeyboardSearch() called"); + } + KeyboardUtil.showKeyboard(activity, searchEditText); + } + + protected void hideKeyboardSearch() { + if (DEBUG) { + Log.d(TAG, "hideKeyboardSearch() called"); + } + + KeyboardUtil.hideKeyboard(activity, searchEditText); + } + + private void showDeleteSuggestionDialog(final SuggestionItem item) { + if (activity == null || historyRecordManager == null || searchEditText == null) { + return; + } + final String query = item.query; + new AlertDialog.Builder(activity) + .setTitle(query) + .setMessage(R.string.delete_item_search_history) + .setCancelable(true) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.delete, (dialog, which) -> { + final Disposable onDelete = historyRecordManager.deleteSearchHistory(query) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + howManyDeleted -> suggestionPublisher + .onNext(getSearchEditString()), + throwable -> showSnackBarError(new ErrorInfo(throwable, + UserAction.DELETE_FROM_HISTORY, + "Deleting item failed"))); + disposables.add(onDelete); + }) + .show(); + } + + @Override + public boolean onBackPressed() { + if (suggestionsPanelVisible + && !infoListAdapter.getItemsList().isEmpty() + && !isLoading.get()) { + hideSuggestionsPanel(); + hideKeyboardSearch(); + searchEditText.setText(lastSearchedString); + return true; + } + return false; + } + + + private Observable> getLocalSuggestionsObservable( + final String query, final int similarQueryLimit) { + return historyRecordManager + .getRelatedSearches(query, similarQueryLimit, 25) + .toObservable() + .map(searchHistoryEntries -> + searchHistoryEntries.stream() + .map(entry -> new SuggestionItem(true, entry)) + .collect(Collectors.toList())); + } + + private Observable> getRemoteSuggestionsObservable(final String query) { + return ExtractorHelper + .suggestionsFor(serviceId, query) + .toObservable() + .map(strings -> { + final List result = new ArrayList<>(); + for (final String entry : strings) { + result.add(new SuggestionItem(false, entry)); + } + return result; + }); + } + + private void initSuggestionObserver() { + if (DEBUG) { + Log.d(TAG, "initSuggestionObserver() called"); + } + if (suggestionDisposable != null) { + suggestionDisposable.dispose(); + } + + suggestionDisposable = suggestionPublisher + .debounce(SUGGESTIONS_DEBOUNCE, TimeUnit.MILLISECONDS) + .startWithItem(searchString == null ? "" : searchString) + .switchMap(query -> { + // Only show remote suggestions if they are enabled in settings and + // the query length is at least THRESHOLD_NETWORK_SUGGESTION + final boolean shallShowRemoteSuggestionsNow = showRemoteSuggestions + && query.length() >= THRESHOLD_NETWORK_SUGGESTION; + + if (showLocalSuggestions && shallShowRemoteSuggestionsNow) { + return Observable.zip( + getLocalSuggestionsObservable(query, 3), + getRemoteSuggestionsObservable(query), + (local, remote) -> { + remote.removeIf(remoteItem -> local.stream().anyMatch( + localItem -> localItem.equals(remoteItem))); + local.addAll(remote); + return local; + }) + .materialize(); + } else if (showLocalSuggestions) { + return getLocalSuggestionsObservable(query, 25) + .materialize(); + } else if (shallShowRemoteSuggestionsNow) { + return getRemoteSuggestionsObservable(query) + .materialize(); + } else { + return Single.fromCallable(Collections::emptyList) + .toObservable() + .materialize(); + } + }) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + listNotification -> { + if (listNotification.isOnNext()) { + if (listNotification.getValue() != null) { + handleSuggestions(listNotification.getValue()); + } + } else if (listNotification.isOnError() + && listNotification.getError() != null + && !ExceptionUtils.isInterruptedCaused( + listNotification.getError())) { + showSnackBarError(new ErrorInfo(listNotification.getError(), + UserAction.GET_SUGGESTIONS, searchString, serviceId)); + } + }, throwable -> showSnackBarError(new ErrorInfo( + throwable, UserAction.GET_SUGGESTIONS, searchString, serviceId))); + } + + @Override + protected void doInitialLoadLogic() { + // no-op + } + + /** + * Perform a search. + * @param theSearchString the trimmed search string + */ + private void search(@NonNull final String theSearchString) { + if (DEBUG) { + Log.d(TAG, "search() called with: query = [" + theSearchString + "]"); + } + if (theSearchString.isEmpty()) { + return; + } + + // Check if theSearchString is a URL which can be opened by NewPipe directly + // and open it if possible. + try { + final StreamingService streamingService = NewPipe.getServiceByUrl(theSearchString); + showLoading(); + disposables.add(Observable + .fromCallable(() -> NavigationHelper.getIntentByLink(activity, + streamingService, theSearchString)) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(intent -> { + getFM().popBackStackImmediate(); + activity.startActivity(intent); + }, throwable -> showTextError(getString(R.string.unsupported_url)))); + return; + } catch (final Exception ignored) { + // Exception occurred, it's not a url + } + + // prepare search + lastSearchedString = this.searchString; + this.searchString = theSearchString; + infoListAdapter.clearStreamItemList(); + hideSuggestionsPanel(); + showMetaInfoInTextView(null, searchBinding.searchMetaInfoTextView, + searchBinding.searchMetaInfoSeparator, disposables); + hideKeyboardSearch(); + + // store search query if search history is enabled + disposables.add(historyRecordManager.onSearched(serviceId, theSearchString) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + ignored -> { + }, + throwable -> showSnackBarError(new ErrorInfo(throwable, UserAction.SEARCHED, + theSearchString, serviceId)) + )); + + // load search results + suggestionPublisher.onNext(theSearchString); + startLoading(false); + } + + @Override + public void startLoading(final boolean forceLoad) { + super.startLoading(forceLoad); + disposables.clear(); + if (searchDisposable != null) { + searchDisposable.dispose(); + } + searchDisposable = ExtractorHelper.searchFor(serviceId, + searchString, + selectedContentFilter, + selectedSortFilter) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .doOnEvent((searchResult, throwable) -> isLoading.set(false)) + .subscribe(this::handleResult, this::onItemError); + } + + @Override + protected void loadMoreItems() { + if (!Page.isValid(nextPage)) { + return; + } + isLoading.set(true); + showListFooter(true); + if (searchDisposable != null) { + searchDisposable.dispose(); + } + searchDisposable = ExtractorHelper.getMoreSearchItems( + serviceId, + searchString, + selectedContentFilter, + selectedSortFilter, + nextPage) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .doOnEvent((nextItemsResult, throwable) -> isLoading.set(false)) + .subscribe(this::handleNextItems, this::onItemError); + } + + @Override + protected boolean hasMoreItems() { + return Page.isValid(nextPage); + } + + @Override + protected void onItemSelected(final InfoItem selectedItem) { + super.onItemSelected(selectedItem); + hideKeyboardSearch(); + } + + private void onItemError(final Throwable exception) { + if (exception instanceof SearchExtractor.NothingFoundException) { + infoListAdapter.clearStreamItemList(); + showEmptyState(); + } else { + showError(new ErrorInfo(exception, UserAction.SEARCHED, searchString, serviceId)); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Utils + //////////////////////////////////////////////////////////////////////////*/ + + public void selectedFilters(@NonNull final List theSelectedContentFilter, + @NonNull final List theSelectedSortFilter) { + + selectedContentFilter = theSelectedContentFilter; + selectedSortFilter = theSelectedSortFilter; + + if (!TextUtils.isEmpty(searchString)) { + search(searchString); + } + } + + private void setQuery(final int theServiceId, + final String theSearchString) { + serviceId = theServiceId; + searchString = theSearchString; + } + + private String getSearchEditString() { + return searchEditText.getText().toString(); + } + + private boolean isSearchEditBlank() { + return isBlank(getSearchEditString()); + } + + /*////////////////////////////////////////////////////////////////////////// + // Suggestion Results + //////////////////////////////////////////////////////////////////////////*/ + + public void handleSuggestions(@NonNull final List suggestions) { + if (DEBUG) { + Log.d(TAG, "handleSuggestions() called with: suggestions = [" + suggestions + "]"); + } + suggestionListAdapter.submitList(suggestions, + () -> searchBinding.suggestionsList.scrollToPosition(0)); + + if (suggestionsPanelVisible && isErrorPanelVisible()) { + hideLoading(); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Contract + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void hideLoading() { + super.hideLoading(); + showListFooter(false); + } + + /*////////////////////////////////////////////////////////////////////////// + // Search Results + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void handleResult(@NonNull final SearchInfo result) { + final List exceptions = result.getErrors(); + if (!exceptions.isEmpty() + && !(exceptions.size() == 1 + && exceptions.get(0) instanceof SearchExtractor.NothingFoundException)) { + showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.SEARCHED, + searchString, serviceId)); + } + + searchSuggestion = result.getSearchSuggestion(); + if (searchSuggestion != null) { + searchSuggestion = searchSuggestion.trim(); + } + isCorrectedSearch = result.isCorrectedSearch(); + + // List cannot be bundled without creating some containers + metaInfo = result.getMetaInfo().toArray(new MetaInfo[0]); + showMetaInfoInTextView(result.getMetaInfo(), searchBinding.searchMetaInfoTextView, + searchBinding.searchMetaInfoSeparator, disposables); + + handleSearchSuggestion(); + + lastSearchedString = searchString; + nextPage = result.getNextPage(); + + if (infoListAdapter.getItemsList().isEmpty()) { + if (!result.getRelatedItems().isEmpty()) { + infoListAdapter.addInfoItemList(result.getRelatedItems()); + } else { + infoListAdapter.clearStreamItemList(); + showEmptyState(); + return; + } + } + + super.handleResult(result); + } + + private void handleSearchSuggestion() { + if (TextUtils.isEmpty(searchSuggestion)) { + searchBinding.correctSuggestion.setVisibility(View.GONE); + } else { + final String helperText = getString(isCorrectedSearch + ? R.string.search_showing_result_for + : R.string.did_you_mean); + + final String highlightedSearchSuggestion = + "" + Html.escapeHtml(searchSuggestion) + ""; + final String text = String.format(helperText, highlightedSearchSuggestion); + searchBinding.correctSuggestion.setText(HtmlCompat.fromHtml(text, + HtmlCompat.FROM_HTML_MODE_LEGACY)); + + searchBinding.correctSuggestion.setOnClickListener(v -> { + searchBinding.correctSuggestion.setVisibility(View.GONE); + search(searchSuggestion); + searchEditText.setText(searchSuggestion); + }); + + searchBinding.correctSuggestion.setOnLongClickListener(v -> { + searchEditText.setText(searchSuggestion); + searchEditText.setSelection(searchSuggestion.length()); + showKeyboardSearch(); + return true; + }); + + searchBinding.correctSuggestion.setVisibility(View.VISIBLE); + } + } + + @Override + public void handleNextItems(final ListExtractor.InfoItemsPage result) { + showListFooter(false); + infoListAdapter.addInfoItemList(result.getItems()); + nextPage = result.getNextPage(); + + if (!result.getErrors().isEmpty()) { + showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.SEARCHED, + "\"" + searchString + "\" → pageUrl: " + nextPage.getUrl() + ", " + + "pageIds: " + nextPage.getIds() + ", " + + "pageCookies: " + nextPage.getCookies(), + serviceId)); + } + super.handleNextItems(result); + } + + @Override + public void handleError() { + super.handleError(); + hideSuggestionsPanel(); + hideKeyboardSearch(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Suggestion item touch helper + //////////////////////////////////////////////////////////////////////////*/ + + public int getSuggestionMovementFlags(@NonNull final RecyclerView.ViewHolder viewHolder) { + final int position = viewHolder.getBindingAdapterPosition(); + if (position == RecyclerView.NO_POSITION) { + return 0; + } + + final SuggestionItem item = suggestionListAdapter.getCurrentList().get(position); + return item.fromHistory ? makeMovementFlags(0, + ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT) : 0; + } + + public void onSuggestionItemSwiped(@NonNull final RecyclerView.ViewHolder viewHolder) { + final int position = viewHolder.getBindingAdapterPosition(); + final String query = suggestionListAdapter.getCurrentList().get(position).query; + final Disposable onDelete = historyRecordManager.deleteSearchHistory(query) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + howManyDeleted -> suggestionPublisher + .onNext(getSearchEditString()), + throwable -> showSnackBarError(new ErrorInfo(throwable, + UserAction.DELETE_FROM_HISTORY, "Deleting item failed"))); + disposables.add(onDelete); + } + + private void showSelectFiltersDialog() { + final FragmentManager fragmentManager = getChildFragmentManager(); + final DialogFragment searchFilterUiDialog; + + final String searchUi = PreferenceManager.getDefaultSharedPreferences(App.getApp()) + .getString(getString(R.string.search_filter_ui_key), + getString(R.string.search_filter_ui_value)); + if (getString(R.string.search_filter_ui_option_menu_style_key).equals(searchUi)) { + searchFilterUiDialog = new SearchFilterOptionMenuAlikeDialogFragment(); + } else if (getString(R.string.search_filter_ui_chip_dialog_key).equals(searchUi)) { + searchFilterUiDialog = new SearchFilterChipDialogFragment(); + } else { // default dialog + searchFilterUiDialog = new SearchFilterDialogFragment(); + } + + searchFilterUiDialog.show(fragmentManager, "fragment_search"); + } +} diff --git a/app/src/braveLegacy/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterDialogGenerator.java b/app/src/braveLegacy/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterDialogGenerator.java new file mode 100644 index 0000000000..d4aba22d3a --- /dev/null +++ b/app/src/braveLegacy/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterDialogGenerator.java @@ -0,0 +1,342 @@ +// Created by evermind-zz 2022, licensed GNU GPL version 3 or later + +package org.schabi.newpipe.fragments.list.search.filter; + +import android.content.Context; +import android.os.Build; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.GridLayout; +import android.widget.Spinner; +import android.widget.TextView; + +import com.google.android.material.chip.Chip; +import com.google.android.material.chip.ChipGroup; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.extractor.search.filter.FilterGroup; +import org.schabi.newpipe.extractor.search.filter.FilterItem; +import org.schabi.newpipe.util.DeviceUtils; +import org.schabi.newpipe.util.ServiceHelper; + +import java.util.List; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public class SearchFilterDialogGenerator extends BaseSearchFilterUiDialogGenerator { + private static final int CHIP_GROUP_ELEMENTS_THRESHOLD = 2; + private static final int CHIP_MIN_TOUCH_TARGET_SIZE_DP = 40; + protected final GridLayout globalLayout; + + public SearchFilterDialogGenerator( + @NonNull final SearchFilterLogic logic, + @NonNull final ViewGroup root, + @NonNull final Context context) { + super(logic, context); + this.globalLayout = createGridLayout(); + root.addView(globalLayout); + } + + @Override + protected void createTitle(@NonNull final String name, + @NonNull final List titleViewElements) { + final TextView titleView = createTitleText(name); + final View separatorLine = createSeparatorLine(); + final View separatorLine2 = createSeparatorLine(); + + globalLayout.addView(separatorLine); + globalLayout.addView(titleView); + globalLayout.addView(separatorLine2); + + titleViewElements.add(titleView); + titleViewElements.add(separatorLine); + titleViewElements.add(separatorLine2); + } + + @Override + protected void createFilterGroup(@NonNull final FilterGroup filterGroup, + @NonNull final UiWrapperMapDelegate wrapperDelegate, + @NonNull final UiSelectorDelegate selectorDelegate) { + final GridLayout.LayoutParams layoutParams = getLayoutParamsViews(); + boolean doSpanDataOverMultipleCells = false; + final UiItemWrapperViews viewsWrapper = new UiItemWrapperViews( + filterGroup.getIdentifier()); + + final TextView filterLabel; + if (filterGroup.getNameId() != null) { + filterLabel = createFilterLabel(filterGroup, layoutParams); + viewsWrapper.add(filterLabel); + } else { + filterLabel = null; + doSpanDataOverMultipleCells = true; + } + + if (filterGroup.isOnlyOneCheckable()) { + if (filterLabel != null) { + globalLayout.addView(filterLabel); + } + + final Spinner filterDataSpinner = new Spinner(context, Spinner.MODE_DROPDOWN); + + final GridLayout.LayoutParams spinnerLp = + clipFreeRightColumnLayoutParams(doSpanDataOverMultipleCells); + setDefaultMargin(spinnerLp); + filterDataSpinner.setLayoutParams(spinnerLp); + setZeroPadding(filterDataSpinner); + + createUiElementsForSingleSelectableItemsFilterGroup( + filterGroup, wrapperDelegate, selectorDelegate, filterDataSpinner); + + viewsWrapper.add(filterDataSpinner); + globalLayout.addView(filterDataSpinner); + + } else { // multiple items in FilterGroup selectable + final ChipGroup chipGroup = new ChipGroup(context); + doSpanDataOverMultipleCells = chooseParentViewForFilterLabelAndAdd( + filterGroup, doSpanDataOverMultipleCells, filterLabel, chipGroup); + + viewsWrapper.add(chipGroup); + globalLayout.addView(chipGroup); + chipGroup.setLayoutParams( + clipFreeRightColumnLayoutParams(doSpanDataOverMultipleCells)); + chipGroup.setSingleLine(false); + + createUiChipElementsForFilterGroupItems( + filterGroup, wrapperDelegate, selectorDelegate, chipGroup); + } + + wrapperDelegate.put(filterGroup.getIdentifier(), viewsWrapper); + } + + @NonNull + protected TextView createFilterLabel(@NonNull final FilterGroup filterGroup, + @NonNull final GridLayout.LayoutParams layoutParams) { + final TextView filterLabel; + filterLabel = new TextView(context); + + filterLabel.setId(filterGroup.getIdentifier()); + filterLabel.setText( + ServiceHelper.getTranslatedFilterString(filterGroup.getNameId(), context)); + filterLabel.setGravity(Gravity.CENTER_VERTICAL); + setDefaultMargin(layoutParams); + setZeroPadding(filterLabel); + + filterLabel.setLayoutParams(layoutParams); + return filterLabel; + } + + private boolean chooseParentViewForFilterLabelAndAdd( + @NonNull final FilterGroup filterGroup, + final boolean doSpanDataOverMultipleCells, + @Nullable final TextView filterLabel, + @NonNull final ChipGroup possibleParentView) { + + boolean spanOverMultipleCells = doSpanDataOverMultipleCells; + if (filterLabel != null) { + // If we have more than CHIP_GROUP_ELEMENTS_THRESHOLD elements to be + // displayed as Chips add its filterLabel as first element to ChipGroup. + // Now the ChipGroup can be spanned over all the cells to use + // the space better. + if (filterGroup.getFilterItems().size() > CHIP_GROUP_ELEMENTS_THRESHOLD) { + possibleParentView.addView(filterLabel); + spanOverMultipleCells = true; + } else { + globalLayout.addView(filterLabel); + } + } + return spanOverMultipleCells; + } + + private void createUiElementsForSingleSelectableItemsFilterGroup( + @NonNull final FilterGroup filterGroup, + @NonNull final UiWrapperMapDelegate wrapperDelegate, + @NonNull final UiSelectorDelegate selectorDelegate, + @NonNull final Spinner filterDataSpinner) { + filterDataSpinner.setAdapter(new SearchFilterDialogSpinnerAdapter( + context, filterGroup, wrapperDelegate, filterDataSpinner)); + + final AdapterView.OnItemSelectedListener listener; + listener = new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(final AdapterView parent, final View view, + final int position, final long id) { + if (view != null) { + selectorDelegate.selectFilter(view.getId()); + } + } + + @Override + public void onNothingSelected(final AdapterView parent) { + // we are only interested onItemSelected() -> no implementation here + } + }; + + filterDataSpinner.setOnItemSelectedListener(listener); + } + + protected void createUiChipElementsForFilterGroupItems( + @NonNull final FilterGroup filterGroup, + @NonNull final UiWrapperMapDelegate wrapperDelegate, + @NonNull final UiSelectorDelegate selectorDelegate, + @NonNull final ChipGroup chipGroup) { + for (final FilterItem item : filterGroup.getFilterItems()) { + + if (item instanceof InjectFilterItem.DividerItem) { + final InjectFilterItem.DividerItem dividerItem = + (InjectFilterItem.DividerItem) item; + + // For the width MATCH_PARENT is necessary as this allows the + // dividerLabel to fill one row of ChipGroup exclusively + final ChipGroup.LayoutParams layoutParams = new ChipGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + final TextView dividerLabel = createDividerLabel(dividerItem, layoutParams); + chipGroup.addView(dividerLabel); + } else { + final Chip chip = createChipView(chipGroup, item); + + final View.OnClickListener listener; + listener = view -> selectorDelegate.selectFilter(view.getId()); + chip.setOnClickListener(listener); + + chipGroup.addView(chip); + wrapperDelegate.put(item.getIdentifier(), + new UiItemWrapperChip(item, chip, chipGroup)); + } + } + } + + @NonNull + private Chip createChipView(@NonNull final ChipGroup chipGroup, + @NonNull final FilterItem item) { + final Chip chip = (Chip) LayoutInflater.from(context).inflate( + R.layout.chip_search_filter, chipGroup, false); + chip.ensureAccessibleTouchTarget( + DeviceUtils.dpToPx(CHIP_MIN_TOUCH_TARGET_SIZE_DP, context)); + chip.setText(ServiceHelper.getTranslatedFilterString(item.getNameId(), context)); + chip.setId(item.getIdentifier()); + chip.setCheckable(true); + return chip; + } + + @NonNull + private TextView createDividerLabel( + @NonNull final InjectFilterItem.DividerItem dividerItem, + @NonNull final ViewGroup.MarginLayoutParams layoutParams) { + final TextView dividerLabel; + dividerLabel = new TextView(context); + dividerLabel.setEnabled(true); + + dividerLabel.setGravity(Gravity.CENTER_VERTICAL); + setDefaultMargin(layoutParams); + dividerLabel.setLayoutParams(layoutParams); + final String menuDividerTitle = + context.getString(dividerItem.getStringResId()); + dividerLabel.setText(menuDividerTitle); + return dividerLabel; + } + + @NonNull + protected SeparatorLineView createSeparatorLine() { + return createSeparatorLine(clipFreeRightColumnLayoutParams(true)); + } + + @NonNull + private TextView createTitleText(final String name) { + final TextView title = createTitleText(name, + clipFreeRightColumnLayoutParams(true)); + title.setGravity(Gravity.CENTER); + return title; + } + + @NonNull + private GridLayout createGridLayout() { + final GridLayout layout = new GridLayout(context); + + layout.setColumnCount(2); + + final GridLayout.LayoutParams layoutParams = new GridLayout.LayoutParams(); + layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT; + layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT; + setDefaultMargin(layoutParams); + layout.setLayoutParams(layoutParams); + + return layout; + } + + @NonNull + protected GridLayout.LayoutParams clipFreeRightColumnLayoutParams(final boolean doColumnSpan) { + final GridLayout.LayoutParams layoutParams = new GridLayout.LayoutParams(); + // https://stackoverflow.com/questions/37744672/gridlayout-children-are-being-clipped + layoutParams.width = 0; + layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT; + layoutParams.setGravity(Gravity.FILL_HORIZONTAL | Gravity.CENTER_VERTICAL); + setDefaultMargin(layoutParams); + + if (doColumnSpan) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + layoutParams.columnSpec = GridLayout.spec(0, 2, 1.0f); + } else { + layoutParams.columnSpec = GridLayout.spec(0, 2, GridLayout.FILL); + } + } + + return layoutParams; + } + + @NonNull + private GridLayout.LayoutParams getLayoutParamsViews() { + final GridLayout.LayoutParams layoutParams = new GridLayout.LayoutParams(); + layoutParams.setGravity(Gravity.CENTER_VERTICAL); + setDefaultMargin(layoutParams); + return layoutParams; + } + + @NonNull + protected ViewGroup.MarginLayoutParams setDefaultMargin( + @NonNull final ViewGroup.MarginLayoutParams layoutParams) { + layoutParams.setMargins( + DeviceUtils.dpToPx(4, context), + DeviceUtils.dpToPx(2, context), + DeviceUtils.dpToPx(4, context), + DeviceUtils.dpToPx(2, context) + ); + return layoutParams; + } + + @NonNull + protected View setZeroPadding(@NonNull final View view) { + view.setPadding(0, 0, 0, 0); + return view; + } + + public static class UiItemWrapperChip extends BaseUiItemWrapper { + + @NonNull + private final ChipGroup chipGroup; + + public UiItemWrapperChip(@NonNull final FilterItem item, + @NonNull final View view, + @NonNull final ChipGroup chipGroup) { + super(item, view); + this.chipGroup = chipGroup; + } + + @Override + public boolean isChecked() { + return ((Chip) view).isChecked(); + } + + @Override + public void setChecked(final boolean checked) { + ((Chip) view).setChecked(checked); + + if (checked) { + chipGroup.check(view.getId()); + } + } + } +} diff --git a/app/src/braveLegacy/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt b/app/src/braveLegacy/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt new file mode 100644 index 0000000000..19c4482b54 --- /dev/null +++ b/app/src/braveLegacy/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt @@ -0,0 +1,532 @@ +package org.schabi.newpipe.local.subscription.dialog + +import android.app.Dialog +import android.os.Bundle +import android.os.Parcelable +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager +import android.widget.Toast +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.content.getSystemService +import androidx.core.os.bundleOf +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.core.widget.ImageViewCompat +import androidx.core.widget.doOnTextChanged +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProvider +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.xwray.groupie.GroupieAdapter +import com.xwray.groupie.OnItemClickListener +import com.xwray.groupie.Section +import icepick.Icepick +import icepick.State +import org.schabi.newpipe.R +import org.schabi.newpipe.database.feed.model.FeedGroupEntity +import org.schabi.newpipe.databinding.DialogFeedGroupCreateBinding +import org.schabi.newpipe.databinding.ToolbarSearchLayoutBinding +import org.schabi.newpipe.fragments.BackPressable +import org.schabi.newpipe.local.subscription.FeedGroupIcon +import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialog.ScreenState.DeleteScreen +import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialog.ScreenState.IconPickerScreen +import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialog.ScreenState.InitialScreen +import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialog.ScreenState.SubscriptionsPickerScreen +import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialogViewModel.DialogEvent.ProcessingEvent +import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialogViewModel.DialogEvent.SuccessEvent +import org.schabi.newpipe.local.subscription.item.ImportSubscriptionsHintPlaceholderItem +import org.schabi.newpipe.local.subscription.item.PickerIconItem +import org.schabi.newpipe.local.subscription.item.PickerSubscriptionItem +import org.schabi.newpipe.util.DeviceUtils +import org.schabi.newpipe.util.ThemeHelper +import java.io.Serializable + +class FeedGroupDialog : DialogFragment(), BackPressable { + private var _feedGroupCreateBinding: DialogFeedGroupCreateBinding? = null + private val feedGroupCreateBinding get() = _feedGroupCreateBinding!! + + private var _searchLayoutBinding: ToolbarSearchLayoutBinding? = null + private val searchLayoutBinding get() = _searchLayoutBinding!! + + private lateinit var viewModel: FeedGroupDialogViewModel + private var groupId: Long = NO_GROUP_SELECTED + private var groupIcon: FeedGroupIcon? = null + private var groupSortOrder: Long = -1 + + sealed class ScreenState : Serializable { + data object InitialScreen : ScreenState() + data object IconPickerScreen : ScreenState() + data object SubscriptionsPickerScreen : ScreenState() + data object DeleteScreen : ScreenState() + } + + @State @JvmField var selectedIcon: FeedGroupIcon? = null + @State @JvmField var selectedSubscriptions: HashSet = HashSet() + @State @JvmField var wasSubscriptionSelectionChanged: Boolean = false + @State @JvmField var currentScreen: ScreenState = InitialScreen + + @State @JvmField var subscriptionsListState: Parcelable? = null + @State @JvmField var iconsListState: Parcelable? = null + @State @JvmField var wasSearchSubscriptionsVisible = false + @State @JvmField var subscriptionsCurrentSearchQuery = "" + @State @JvmField var subscriptionsShowOnlyUngrouped = false + + private val subscriptionMainSection = Section() + private val subscriptionEmptyFooter = Section() + private lateinit var subscriptionGroupAdapter: GroupieAdapter + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + Icepick.restoreInstanceState(this, savedInstanceState) + + setStyle(STYLE_NO_TITLE, ThemeHelper.getMinWidthDialogTheme(requireContext())) + groupId = arguments?.getLong(KEY_GROUP_ID, NO_GROUP_SELECTED) ?: NO_GROUP_SELECTED + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.dialog_feed_group_create, container) + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return object : Dialog(requireActivity(), theme) { + override fun onBackPressed() { + if (!this@FeedGroupDialog.onBackPressed()) { + super.onBackPressed() + } + } + } + } + + override fun onPause() { + super.onPause() + + wasSearchSubscriptionsVisible = isSearchVisible() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + + iconsListState = feedGroupCreateBinding.iconSelector.layoutManager?.onSaveInstanceState() + subscriptionsListState = feedGroupCreateBinding.subscriptionsSelectorList.layoutManager?.onSaveInstanceState() + + Icepick.saveInstanceState(this, outState) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + _feedGroupCreateBinding = DialogFeedGroupCreateBinding.bind(view) + _searchLayoutBinding = feedGroupCreateBinding.subscriptionsHeaderSearchContainer + + if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.LOLLIPOP) { + // KitKat doesn't apply container's theme to content + val contrastColor = AppCompatResources.getColorStateList(requireContext(), R.color.contrastColor) + searchLayoutBinding.toolbarSearchEditText.setTextColor(contrastColor) + searchLayoutBinding.toolbarSearchEditText.setHintTextColor(contrastColor.withAlpha(128)) + ImageViewCompat.setImageTintList(searchLayoutBinding.toolbarSearchClearIcon, contrastColor) + } + + viewModel = ViewModelProvider( + this, + FeedGroupDialogViewModel.getFactory( + requireContext(), + groupId, + subscriptionsCurrentSearchQuery, + subscriptionsShowOnlyUngrouped + ) + )[FeedGroupDialogViewModel::class.java] + + viewModel.groupLiveData.observe(viewLifecycleOwner, Observer(::handleGroup)) + viewModel.subscriptionsLiveData.observe(viewLifecycleOwner) { + setupSubscriptionPicker(it.first, it.second) + } + viewModel.dialogEventLiveData.observe(viewLifecycleOwner) { + when (it) { + ProcessingEvent -> disableInput() + SuccessEvent -> dismiss() + } + } + + subscriptionGroupAdapter = GroupieAdapter().apply { + add(subscriptionMainSection) + add(subscriptionEmptyFooter) + spanCount = 4 + } + feedGroupCreateBinding.subscriptionsSelectorList.apply { + // Disable animations, too distracting. + itemAnimator = null + adapter = subscriptionGroupAdapter + layoutManager = GridLayoutManager( + requireContext(), subscriptionGroupAdapter.spanCount, + RecyclerView.VERTICAL, false + ).apply { + spanSizeLookup = subscriptionGroupAdapter.spanSizeLookup + } + } + + setupIconPicker() + setupListeners() + + showScreen(currentScreen) + + if (currentScreen == SubscriptionsPickerScreen && wasSearchSubscriptionsVisible) { + showSearch() + } else if (currentScreen == InitialScreen && groupId == NO_GROUP_SELECTED) { + showKeyboard() + } + } + + override fun onDestroyView() { + super.onDestroyView() + feedGroupCreateBinding.subscriptionsSelectorList.adapter = null + feedGroupCreateBinding.iconSelector.adapter = null + + _feedGroupCreateBinding = null + _searchLayoutBinding = null + } + + /*/​////////////////////////////////////////////////////////////////////////// + // Setup + //​//////////////////////////////////////////////////////////////////////// */ + + override fun onBackPressed(): Boolean { + if (currentScreen is SubscriptionsPickerScreen && isSearchVisible()) { + hideSearch() + return true + } else if (currentScreen !is InitialScreen) { + showScreen(InitialScreen) + return true + } + + return false + } + + private fun setupListeners() { + feedGroupCreateBinding.deleteButton.setOnClickListener { showScreen(DeleteScreen) } + + feedGroupCreateBinding.cancelButton.setOnClickListener { + when (currentScreen) { + InitialScreen -> dismiss() + else -> showScreen(InitialScreen) + } + } + + feedGroupCreateBinding.groupNameInputContainer.error = null + feedGroupCreateBinding.groupNameInput.doOnTextChanged { text, _, _, _ -> + if (feedGroupCreateBinding.groupNameInputContainer.isErrorEnabled && !text.isNullOrBlank()) { + feedGroupCreateBinding.groupNameInputContainer.error = null + } + } + + feedGroupCreateBinding.confirmButton.setOnClickListener { handlePositiveButton() } + + feedGroupCreateBinding.selectChannelButton.setOnClickListener { + feedGroupCreateBinding.subscriptionsSelectorList.scrollToPosition(0) + showScreen(SubscriptionsPickerScreen) + } + + val headerMenu = feedGroupCreateBinding.subscriptionsHeaderToolbar.menu + requireActivity().menuInflater.inflate(R.menu.menu_feed_group_dialog, headerMenu) + + headerMenu.findItem(R.id.action_search).setOnMenuItemClickListener { + showSearch() + true + } + + headerMenu.findItem(R.id.feed_group_toggle_show_only_ungrouped_subscriptions).apply { + isChecked = subscriptionsShowOnlyUngrouped + setOnMenuItemClickListener { + subscriptionsShowOnlyUngrouped = !subscriptionsShowOnlyUngrouped + it.isChecked = subscriptionsShowOnlyUngrouped + viewModel.toggleShowOnlyUngrouped(subscriptionsShowOnlyUngrouped) + true + } + } + + searchLayoutBinding.toolbarSearchClear.setOnClickListener { + if (searchLayoutBinding.toolbarSearchEditText.text.isNullOrEmpty()) { + hideSearch() + return@setOnClickListener + } + resetSearch() + showKeyboardSearch() + } + + searchLayoutBinding.toolbarSearchEditText.setOnClickListener { + if (DeviceUtils.isTv(context)) { + showKeyboardSearch() + } + } + + searchLayoutBinding.toolbarSearchEditText.doOnTextChanged { _, _, _, _ -> + val newQuery: String = searchLayoutBinding.toolbarSearchEditText.text.toString() + subscriptionsCurrentSearchQuery = newQuery + viewModel.filterSubscriptionsBy(newQuery) + } + + subscriptionGroupAdapter.setOnItemClickListener(subscriptionPickerItemListener) + } + + private fun handlePositiveButton() = when { + currentScreen is InitialScreen -> handlePositiveButtonInitialScreen() + currentScreen is DeleteScreen -> viewModel.deleteGroup() + currentScreen is SubscriptionsPickerScreen && isSearchVisible() -> hideSearch() + else -> showScreen(InitialScreen) + } + + private fun handlePositiveButtonInitialScreen() { + val name = feedGroupCreateBinding.groupNameInput.text.toString().trim() + val icon = selectedIcon ?: groupIcon ?: FeedGroupIcon.ALL + + if (name.isBlank()) { + feedGroupCreateBinding.groupNameInputContainer.error = getString(R.string.feed_group_dialog_empty_name) + feedGroupCreateBinding.groupNameInput.text = null + feedGroupCreateBinding.groupNameInput.requestFocus() + return + } else { + feedGroupCreateBinding.groupNameInputContainer.error = null + } + + if (selectedSubscriptions.isEmpty()) { + Toast.makeText(requireContext(), getString(R.string.feed_group_dialog_empty_selection), Toast.LENGTH_SHORT).show() + return + } + + when (groupId) { + NO_GROUP_SELECTED -> viewModel.createGroup(name, icon, selectedSubscriptions) + else -> viewModel.updateGroup(name, icon, selectedSubscriptions, groupSortOrder) + } + } + + private fun handleGroup(feedGroupEntity: FeedGroupEntity? = null) { + val icon = feedGroupEntity?.icon ?: FeedGroupIcon.ALL + val name = feedGroupEntity?.name ?: "" + groupIcon = feedGroupEntity?.icon + groupSortOrder = feedGroupEntity?.sortOrder ?: -1 + + val feedGroupIcon = if (selectedIcon == null) icon else selectedIcon!! + feedGroupCreateBinding.iconPreview.setImageResource(feedGroupIcon.getDrawableRes()) + + if (feedGroupCreateBinding.groupNameInput.text.isNullOrBlank()) { + feedGroupCreateBinding.groupNameInput.setText(name) + } + } + + private val subscriptionPickerItemListener = OnItemClickListener { item, view -> + if (item is PickerSubscriptionItem) { + val subscriptionId = item.subscriptionEntity.uid + wasSubscriptionSelectionChanged = true + + val isSelected = if (this.selectedSubscriptions.contains(subscriptionId)) { + this.selectedSubscriptions.remove(subscriptionId) + false + } else { + this.selectedSubscriptions.add(subscriptionId) + true + } + + item.updateSelected(view, isSelected) + updateSubscriptionSelectedCount() + } + } + + private fun setupSubscriptionPicker( + subscriptions: List, + selectedSubscriptions: Set + ) { + if (!wasSubscriptionSelectionChanged) { + this.selectedSubscriptions.addAll(selectedSubscriptions) + } + + updateSubscriptionSelectedCount() + + if (subscriptions.isEmpty()) { + subscriptionEmptyFooter.clear() + subscriptionEmptyFooter.add(ImportSubscriptionsHintPlaceholderItem()) + } else { + subscriptionEmptyFooter.clear() + } + + subscriptions.forEach { + it.isSelected = this@FeedGroupDialog.selectedSubscriptions + .contains(it.subscriptionEntity.uid) + } + + subscriptionMainSection.update(subscriptions, false) + + if (subscriptionsListState != null) { + feedGroupCreateBinding.subscriptionsSelectorList.layoutManager?.onRestoreInstanceState(subscriptionsListState) + subscriptionsListState = null + } else { + feedGroupCreateBinding.subscriptionsSelectorList.scrollToPosition(0) + } + } + + private fun updateSubscriptionSelectedCount() { + val selectedCount = this.selectedSubscriptions.size + val selectedCountText = resources.getQuantityString( + R.plurals.feed_group_dialog_selection_count, + selectedCount, selectedCount + ) + feedGroupCreateBinding.selectedSubscriptionCountView.text = selectedCountText + feedGroupCreateBinding.subscriptionsHeaderInfo.text = selectedCountText + } + + private fun setupIconPicker() { + val groupAdapter = GroupieAdapter() + groupAdapter.addAll(FeedGroupIcon.entries.map { PickerIconItem(it) }) + + feedGroupCreateBinding.iconSelector.apply { + layoutManager = GridLayoutManager(requireContext(), 7, RecyclerView.VERTICAL, false) + adapter = groupAdapter + + if (iconsListState != null) { + layoutManager?.onRestoreInstanceState(iconsListState) + iconsListState = null + } + } + + groupAdapter.setOnItemClickListener { item, _ -> + when (item) { + is PickerIconItem -> { + selectedIcon = item.icon + feedGroupCreateBinding.iconPreview.setImageResource(item.iconRes) + + showScreen(InitialScreen) + } + } + } + feedGroupCreateBinding.iconPreview.setOnClickListener { + feedGroupCreateBinding.iconSelector.scrollToPosition(0) + showScreen(IconPickerScreen) + } + + if (groupId == NO_GROUP_SELECTED) { + val icon = selectedIcon ?: FeedGroupIcon.ALL + feedGroupCreateBinding.iconPreview.setImageResource(icon.getDrawableRes()) + } + } + + /*/​////////////////////////////////////////////////////////////////////////// + // Screen Selector + //​//////////////////////////////////////////////////////////////////////// */ + + private fun showScreen(screen: ScreenState) { + currentScreen = screen + + feedGroupCreateBinding.optionsRoot.onlyVisibleIn(InitialScreen) + feedGroupCreateBinding.iconSelector.onlyVisibleIn(IconPickerScreen) + feedGroupCreateBinding.subscriptionsSelector.onlyVisibleIn(SubscriptionsPickerScreen) + feedGroupCreateBinding.deleteScreenMessage.onlyVisibleIn(DeleteScreen) + + feedGroupCreateBinding.separator.onlyVisibleIn(SubscriptionsPickerScreen, IconPickerScreen) + feedGroupCreateBinding.cancelButton.onlyVisibleIn(InitialScreen, DeleteScreen) + + feedGroupCreateBinding.confirmButton.setText( + when { + currentScreen == InitialScreen && groupId == NO_GROUP_SELECTED -> R.string.create + else -> R.string.ok + } + ) + + feedGroupCreateBinding.deleteButton.isGone = currentScreen != InitialScreen || groupId == NO_GROUP_SELECTED + + hideKeyboard() + hideSearch() + } + + private fun View.onlyVisibleIn(vararg screens: ScreenState) { + isVisible = currentScreen in screens + } + + /*/​////////////////////////////////////////////////////////////////////////// + // Utils + //​//////////////////////////////////////////////////////////////////////// */ + + private fun isSearchVisible() = _searchLayoutBinding?.root?.visibility == View.VISIBLE + + private fun resetSearch() { + searchLayoutBinding.toolbarSearchEditText.setText("") + subscriptionsCurrentSearchQuery = "" + viewModel.clearSubscriptionsFilter() + } + + private fun hideSearch() { + resetSearch() + searchLayoutBinding.root.visibility = View.GONE + feedGroupCreateBinding.subscriptionsHeaderInfoContainer.visibility = View.VISIBLE + feedGroupCreateBinding.subscriptionsHeaderToolbar.menu.findItem(R.id.action_search).isVisible = true + hideKeyboardSearch() + } + + private fun showSearch() { + searchLayoutBinding.root.visibility = View.VISIBLE + feedGroupCreateBinding.subscriptionsHeaderInfoContainer.visibility = View.GONE + feedGroupCreateBinding.subscriptionsHeaderToolbar.menu.findItem(R.id.action_search).isVisible = false + showKeyboardSearch() + } + + private val inputMethodManager by lazy { + requireActivity().getSystemService()!! + } + + private fun showKeyboardSearch() { + if (searchLayoutBinding.toolbarSearchEditText.requestFocus()) { + inputMethodManager.showSoftInput( + searchLayoutBinding.toolbarSearchEditText, + InputMethodManager.SHOW_IMPLICIT + ) + } + } + + private fun hideKeyboardSearch() { + inputMethodManager.hideSoftInputFromWindow( + searchLayoutBinding.toolbarSearchEditText.windowToken, + InputMethodManager.RESULT_UNCHANGED_SHOWN + ) + searchLayoutBinding.toolbarSearchEditText.clearFocus() + } + + private fun showKeyboard() { + if (feedGroupCreateBinding.groupNameInput.requestFocus()) { + inputMethodManager.showSoftInput( + feedGroupCreateBinding.groupNameInput, + InputMethodManager.SHOW_IMPLICIT + ) + } + } + + private fun hideKeyboard() { + inputMethodManager.hideSoftInputFromWindow( + feedGroupCreateBinding.groupNameInput.windowToken, + InputMethodManager.RESULT_UNCHANGED_SHOWN + ) + feedGroupCreateBinding.groupNameInput.clearFocus() + } + + private fun disableInput() { + _feedGroupCreateBinding?.deleteButton?.isEnabled = false + _feedGroupCreateBinding?.confirmButton?.isEnabled = false + _feedGroupCreateBinding?.cancelButton?.isEnabled = false + isCancelable = false + + hideKeyboard() + } + + companion object { + private const val KEY_GROUP_ID = "KEY_GROUP_ID" + private const val NO_GROUP_SELECTED = -1L + + fun newInstance(groupId: Long = NO_GROUP_SELECTED): FeedGroupDialog { + val dialog = FeedGroupDialog() + dialog.arguments = bundleOf(KEY_GROUP_ID to groupId) + return dialog + } + } +} diff --git a/app/src/braveLegacy/java/org/schabi/newpipe/player/ui/MainPlayerUi.java b/app/src/braveLegacy/java/org/schabi/newpipe/player/ui/MainPlayerUi.java new file mode 100644 index 0000000000..7143dbcedc --- /dev/null +++ b/app/src/braveLegacy/java/org/schabi/newpipe/player/ui/MainPlayerUi.java @@ -0,0 +1,988 @@ +package org.schabi.newpipe.player.ui; + +import static android.view.ViewGroup.LayoutParams.MATCH_PARENT; +import static org.schabi.newpipe.MainActivity.DEBUG; +import static org.schabi.newpipe.QueueItemMenuUtil.openPopupMenu; +import static org.schabi.newpipe.extractor.ServiceList.YouTube; +import static org.schabi.newpipe.ktx.ViewUtils.animate; +import static org.schabi.newpipe.player.Player.STATE_COMPLETED; +import static org.schabi.newpipe.player.Player.STATE_PAUSED; +import static org.schabi.newpipe.player.helper.PlayerHelper.MinimizeMode.MINIMIZE_ON_EXIT_MODE_BACKGROUND; +import static org.schabi.newpipe.player.helper.PlayerHelper.MinimizeMode.MINIMIZE_ON_EXIT_MODE_NONE; +import static org.schabi.newpipe.player.helper.PlayerHelper.MinimizeMode.MINIMIZE_ON_EXIT_MODE_POPUP; +import static org.schabi.newpipe.player.helper.PlayerHelper.getMinimizeOnExitAction; +import static org.schabi.newpipe.player.helper.PlayerHelper.getTimeString; +import static org.schabi.newpipe.player.helper.PlayerHelper.globalScreenOrientationLocked; +import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_PAUSE; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.content.res.Resources; +import android.database.ContentObserver; +import android.graphics.Bitmap; +import android.graphics.Color; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.provider.Settings; +import android.util.DisplayMetrics; +import android.util.Log; +import android.util.TypedValue; +import android.view.KeyEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewParent; +import android.view.WindowManager; +import android.widget.FrameLayout; +import android.widget.LinearLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.content.res.AppCompatResources; +import androidx.fragment.app.FragmentActivity; +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.exoplayer2.video.VideoSize; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.databinding.PlayerBinding; +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.extractor.stream.StreamSegment; +import org.schabi.newpipe.fragments.OnScrollBelowItemsListener; +import org.schabi.newpipe.fragments.detail.VideoDetailFragment; +import org.schabi.newpipe.info_list.StreamSegmentAdapter; +import org.schabi.newpipe.info_list.StreamSegmentItem; +import org.schabi.newpipe.ktx.AnimationType; +import org.schabi.newpipe.local.dialog.PlaylistDialog; +import org.schabi.newpipe.player.Player; +import org.schabi.newpipe.player.event.PlayerServiceEventListener; +import org.schabi.newpipe.player.gesture.BasePlayerGestureListener; +import org.schabi.newpipe.player.gesture.MainPlayerGestureListener; +import org.schabi.newpipe.player.helper.PlaybackParameterDialog; +import org.schabi.newpipe.player.helper.PlayerHelper; +import org.schabi.newpipe.player.mediaitem.MediaItemTag; +import org.schabi.newpipe.player.playqueue.PlayQueue; +import org.schabi.newpipe.player.playqueue.PlayQueueAdapter; +import org.schabi.newpipe.player.playqueue.PlayQueueItem; +import org.schabi.newpipe.player.playqueue.PlayQueueItemBuilder; +import org.schabi.newpipe.player.playqueue.PlayQueueItemHolder; +import org.schabi.newpipe.player.playqueue.PlayQueueItemTouchCallback; +import org.schabi.newpipe.util.DeviceUtils; +import org.schabi.newpipe.util.NavigationHelper; +import org.schabi.newpipe.util.external_communication.KoreUtils; +import org.schabi.newpipe.util.external_communication.ShareUtils; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +public final class MainPlayerUi extends VideoPlayerUi implements View.OnLayoutChangeListener { + private static final String TAG = MainPlayerUi.class.getSimpleName(); + + // see the Javadoc of calculateMaxEndScreenThumbnailHeight for information + private static final int DETAIL_ROOT_MINIMUM_HEIGHT = 85; // dp + private static final int DETAIL_TITLE_TEXT_SIZE_TV = 16; // sp + private static final int DETAIL_TITLE_TEXT_SIZE_TABLET = 15; // sp + + private boolean isFullscreen = false; + private boolean isVerticalVideo = false; + private boolean fragmentIsVisible = false; + + private ContentObserver settingsContentObserver; + + private PlayQueueAdapter playQueueAdapter; + private StreamSegmentAdapter segmentAdapter; + private boolean isQueueVisible = false; + private boolean areSegmentsVisible = false; + + // fullscreen player + private ItemTouchHelper itemTouchHelper; + + /*////////////////////////////////////////////////////////////////////////// + // Constructor, setup, destroy + //////////////////////////////////////////////////////////////////////////*/ + //region Constructor, setup, destroy + + public MainPlayerUi(@NonNull final Player player, + @NonNull final PlayerBinding playerBinding) { + super(player, playerBinding); + } + + /** + * Open fullscreen on tablets where the option to have the main player start automatically in + * fullscreen mode is on. Rotating the device to landscape is already done in {@link + * VideoDetailFragment#openVideoPlayer(boolean)} when the thumbnail is clicked, and that's + * enough for phones, but not for tablets since the mini player can be also shown in landscape. + */ + private void directlyOpenFullscreenIfNeeded() { + if (PlayerHelper.isStartMainPlayerFullscreenEnabled(player.getService()) + && DeviceUtils.isTablet(player.getService()) + && PlayerHelper.globalScreenOrientationLocked(player.getService())) { + player.getFragmentListener().ifPresent( + PlayerServiceEventListener::onScreenRotationButtonClicked); + } + } + + @Override + public void setupAfterIntent() { + // needed for tablets, check the function for a better explanation + directlyOpenFullscreenIfNeeded(); + + super.setupAfterIntent(); + + initVideoPlayer(); + // Android TV: without it focus will frame the whole player + binding.playPauseButton.requestFocus(); + + // Note: This is for automatically playing (when "Resume playback" is off), see #6179 + if (player.getPlayWhenReady()) { + player.play(); + } else { + player.pause(); + } + } + + @Override + BasePlayerGestureListener buildGestureListener() { + return new MainPlayerGestureListener(this); + } + + @Override + protected void initListeners() { + super.initListeners(); + + binding.screenRotationButton.setOnClickListener(makeOnClickListener(() -> { + // Only if it's not a vertical video or vertical video but in landscape with locked + // orientation a screen orientation can be changed automatically + if (!isVerticalVideo || (isLandscape() && globalScreenOrientationLocked(context))) { + player.getFragmentListener() + .ifPresent(PlayerServiceEventListener::onScreenRotationButtonClicked); + } else { + toggleFullscreen(); + } + })); + binding.queueButton.setOnClickListener(v -> onQueueClicked()); + binding.segmentsButton.setOnClickListener(v -> onSegmentsClicked()); + + binding.addToPlaylistButton.setOnClickListener(v -> + getParentActivity().map(FragmentActivity::getSupportFragmentManager) + .ifPresent(fragmentManager -> + PlaylistDialog.showForPlayQueue(player, fragmentManager))); + + settingsContentObserver = new ContentObserver(new Handler(Looper.getMainLooper())) { + @Override + public void onChange(final boolean selfChange) { + setupScreenRotationButton(); + } + }; + context.getContentResolver().registerContentObserver( + Settings.System.getUriFor(Settings.System.ACCELEROMETER_ROTATION), false, + settingsContentObserver); + + binding.getRoot().addOnLayoutChangeListener(this); + + binding.moreOptionsButton.setOnLongClickListener(v -> { + player.getFragmentListener() + .ifPresent(PlayerServiceEventListener::onMoreOptionsLongClicked); + hideControls(0, 0); + hideSystemUIIfNeeded(); + return true; + }); + } + + @Override + protected void deinitListeners() { + super.deinitListeners(); + + binding.queueButton.setOnClickListener(null); + binding.segmentsButton.setOnClickListener(null); + binding.addToPlaylistButton.setOnClickListener(null); + + context.getContentResolver().unregisterContentObserver(settingsContentObserver); + + binding.getRoot().removeOnLayoutChangeListener(this); + } + + @Override + public void initPlayback() { + super.initPlayback(); + + if (playQueueAdapter != null) { + playQueueAdapter.dispose(); + } + playQueueAdapter = new PlayQueueAdapter(context, + Objects.requireNonNull(player.getPlayQueue())); + segmentAdapter = new StreamSegmentAdapter(getStreamSegmentListener()); + } + + @Override + public void removeViewFromParent() { + // view was added to fragment + final ViewParent parent = binding.getRoot().getParent(); + if (parent instanceof ViewGroup) { + ((ViewGroup) parent).removeView(binding.getRoot()); + } + } + + @Override + public void destroy() { + super.destroy(); + + // Exit from fullscreen when user closes the player via notification + if (isFullscreen) { + toggleFullscreen(); + } + + removeViewFromParent(); + } + + @Override + public void destroyPlayer() { + super.destroyPlayer(); + + if (playQueueAdapter != null) { + playQueueAdapter.unsetSelectedListener(); + playQueueAdapter.dispose(); + } + } + + @Override + public void smoothStopForImmediateReusing() { + super.smoothStopForImmediateReusing(); + // Android TV will handle back button in case controls will be visible + // (one more additional unneeded click while the player is hidden) + hideControls(0, 0); + closeItemsList(); + } + + private void initVideoPlayer() { + // restore last resize mode + setResizeMode(PlayerHelper.retrieveResizeModeFromPrefs(player)); + binding.getRoot().setLayoutParams(new FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)); + } + + @Override + protected void setupElementsVisibility() { + super.setupElementsVisibility(); + + closeItemsList(); + showHideKodiButton(); + binding.fullScreenButton.setVisibility(View.GONE); + setupScreenRotationButton(); + binding.resizeTextView.setVisibility(View.VISIBLE); + binding.getRoot().findViewById(R.id.metadataView).setVisibility(View.VISIBLE); + binding.moreOptionsButton.setVisibility(View.VISIBLE); + binding.topControls.setOrientation(LinearLayout.VERTICAL); + binding.primaryControls.getLayoutParams().width = MATCH_PARENT; + binding.secondaryControls.setVisibility(View.INVISIBLE); + binding.moreOptionsButton.setImageDrawable(AppCompatResources.getDrawable(context, + R.drawable.ic_expand_more)); + binding.share.setVisibility(View.VISIBLE); + binding.openInBrowser.setVisibility(View.VISIBLE); + binding.switchMute.setVisibility(View.VISIBLE); + binding.playerCloseButton.setVisibility(isFullscreen ? View.GONE : View.VISIBLE); + // Top controls have a large minHeight which is allows to drag the player + // down in fullscreen mode (just larger area to make easy to locate by finger) + binding.topControls.setClickable(true); + binding.topControls.setFocusable(true); + + binding.titleTextView.setVisibility(isFullscreen ? View.VISIBLE : View.GONE); + binding.channelTextView.setVisibility(isFullscreen ? View.VISIBLE : View.GONE); + } + + @Override + protected void setupElementsSize(final Resources resources) { + setupElementsSize( + resources.getDimensionPixelSize(R.dimen.player_main_buttons_min_width), + resources.getDimensionPixelSize(R.dimen.player_main_top_padding), + resources.getDimensionPixelSize(R.dimen.player_main_controls_padding), + resources.getDimensionPixelSize(R.dimen.player_main_buttons_padding) + ); + } + //endregion + + + /*////////////////////////////////////////////////////////////////////////// + // Broadcast receiver + //////////////////////////////////////////////////////////////////////////*/ + //region Broadcast receiver + + @Override + public void onBroadcastReceived(final Intent intent) { + super.onBroadcastReceived(intent); + if (Intent.ACTION_CONFIGURATION_CHANGED.equals(intent.getAction())) { + // Close it because when changing orientation from portrait + // (in fullscreen mode) the size of queue layout can be larger than the screen size + closeItemsList(); + } else if (ACTION_PLAY_PAUSE.equals(intent.getAction())) { + // Ensure that we have audio-only stream playing when a user + // started to play from notification's play button from outside of the app + if (!fragmentIsVisible) { + onFragmentStopped(); + } + } else if (VideoDetailFragment.ACTION_VIDEO_FRAGMENT_STOPPED.equals(intent.getAction())) { + fragmentIsVisible = false; + onFragmentStopped(); + } else if (VideoDetailFragment.ACTION_VIDEO_FRAGMENT_RESUMED.equals(intent.getAction())) { + // Restore video source when user returns to the fragment + fragmentIsVisible = true; + player.useVideoSource(true); + + // When a user returns from background, the system UI will always be shown even if + // controls are invisible: hide it in that case + if (!isControlsVisible()) { + hideSystemUIIfNeeded(); + } + } + } + //endregion + + + /*////////////////////////////////////////////////////////////////////////// + // Fragment binding + //////////////////////////////////////////////////////////////////////////*/ + //region Fragment binding + + @Override + public void onFragmentListenerSet() { + super.onFragmentListenerSet(); + fragmentIsVisible = true; + // Apply window insets because Android will not do it when orientation changes + // from landscape to portrait + if (!isFullscreen) { + binding.playbackControlRoot.setPadding(0, 0, 0, 0); + } + binding.itemsListPanel.setPadding(0, 0, 0, 0); + player.getFragmentListener().ifPresent(PlayerServiceEventListener::onViewCreated); + } + + /** + * This will be called when a user goes to another app/activity, turns off a screen. + * We don't want to interrupt playback and don't want to see notification so + * next lines of code will enable audio-only playback only if needed + */ + private void onFragmentStopped() { + if (player.isPlaying() || player.isLoading()) { + switch (getMinimizeOnExitAction(context)) { + case MINIMIZE_ON_EXIT_MODE_BACKGROUND: + player.useVideoSource(false); + break; + case MINIMIZE_ON_EXIT_MODE_POPUP: + getParentActivity().ifPresent(activity -> { + player.setRecovery(); + NavigationHelper.playOnPopupPlayer(activity, player.getPlayQueue(), true); + }); + break; + case MINIMIZE_ON_EXIT_MODE_NONE: default: + player.pause(); + break; + } + } + } + //endregion + + + /*////////////////////////////////////////////////////////////////////////// + // Playback states + //////////////////////////////////////////////////////////////////////////*/ + //region Playback states + + @Override + public void onUpdateProgress(final int currentProgress, + final int duration, + final int bufferPercent) { + super.onUpdateProgress(currentProgress, duration, bufferPercent); + + if (areSegmentsVisible) { + segmentAdapter.selectSegmentAt(getNearestStreamSegmentPosition(currentProgress)); + } + if (isQueueVisible) { + updateQueueTime(currentProgress); + } + } + + @Override + public void onPlaying() { + super.onPlaying(); + checkLandscape(); + } + + @Override + public void onCompleted() { + super.onCompleted(); + if (isFullscreen) { + toggleFullscreen(); + } + } + //endregion + + + /*////////////////////////////////////////////////////////////////////////// + // Controls showing / hiding + //////////////////////////////////////////////////////////////////////////*/ + //region Controls showing / hiding + + @Override + protected void showOrHideButtons() { + super.showOrHideButtons(); + @Nullable final PlayQueue playQueue = player.getPlayQueue(); + if (playQueue == null) { + return; + } + + final boolean showQueue = !playQueue.getStreams().isEmpty(); + final boolean showSegment = !player.getCurrentStreamInfo() + .map(StreamInfo::getStreamSegments) + .map(List::isEmpty) + .orElse(/*no stream info=*/true); + + binding.queueButton.setVisibility(showQueue ? View.VISIBLE : View.GONE); + binding.queueButton.setAlpha(showQueue ? 1.0f : 0.0f); + binding.segmentsButton.setVisibility(showSegment ? View.VISIBLE : View.GONE); + binding.segmentsButton.setAlpha(showSegment ? 1.0f : 0.0f); + } + + @Override + public void showSystemUIPartially() { + if (isFullscreen) { + getParentActivity().map(Activity::getWindow).ifPresent(window -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + window.setStatusBarColor(Color.TRANSPARENT); + window.setNavigationBarColor(Color.TRANSPARENT); + } + final int visibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE + | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION; + window.getDecorView().setSystemUiVisibility(visibility); + window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + }); + } + } + + @Override + public void hideSystemUIIfNeeded() { + player.getFragmentListener().ifPresent(PlayerServiceEventListener::hideSystemUiIfNeeded); + } + + /** + * Calculate the maximum allowed height for the {@link R.id.endScreen} + * to prevent it from enlarging the player. + *

+ * The calculating follows these rules: + *

    + *
  • + * Show at least stream title and content creator on TVs and tablets when in landscape + * (always the case for TVs) and not in fullscreen mode. This requires to have at least + * {@link #DETAIL_ROOT_MINIMUM_HEIGHT} free space for {@link R.id.detail_root} and + * additional space for the stream title text size ({@link R.id.detail_title_root_layout}). + * The text size is {@link #DETAIL_TITLE_TEXT_SIZE_TABLET} on tablets and + * {@link #DETAIL_TITLE_TEXT_SIZE_TV} on TVs, see {@link R.id.titleTextView}. + *
  • + *
  • + * Otherwise, the max thumbnail height is the screen height. + *
  • + *
+ * + * @param bitmap the bitmap that needs to be resized to fit the end screen + * @return the maximum height for the end screen thumbnail + */ + @Override + protected float calculateMaxEndScreenThumbnailHeight(@NonNull final Bitmap bitmap) { + final int screenHeight = context.getResources().getDisplayMetrics().heightPixels; + + if (DeviceUtils.isTv(context) && !isFullscreen()) { + final int videoInfoHeight = DeviceUtils.dpToPx(DETAIL_ROOT_MINIMUM_HEIGHT, context) + + DeviceUtils.spToPx(DETAIL_TITLE_TEXT_SIZE_TV, context); + return Math.min(bitmap.getHeight(), screenHeight - videoInfoHeight); + } else if (DeviceUtils.isTablet(context) && isLandscape() && !isFullscreen()) { + final int videoInfoHeight = DeviceUtils.dpToPx(DETAIL_ROOT_MINIMUM_HEIGHT, context) + + DeviceUtils.spToPx(DETAIL_TITLE_TEXT_SIZE_TABLET, context); + return Math.min(bitmap.getHeight(), screenHeight - videoInfoHeight); + } else { // fullscreen player: max height is the device height + return Math.min(bitmap.getHeight(), screenHeight); + } + } + + private void showHideKodiButton() { + // show kodi button if it supports the current service and it is enabled in settings + @Nullable final PlayQueue playQueue = player.getPlayQueue(); + binding.playWithKodi.setVisibility(playQueue != null && playQueue.getItem() != null + && KoreUtils.shouldShowPlayWithKodi(context, playQueue.getItem().getServiceId()) + ? View.VISIBLE : View.GONE); + } + //endregion + + + /*////////////////////////////////////////////////////////////////////////// + // Captions (text tracks) + //////////////////////////////////////////////////////////////////////////*/ + //region Captions (text tracks) + + @Override + protected void setupSubtitleView(final float captionScale) { + final DisplayMetrics metrics = context.getResources().getDisplayMetrics(); + final int minimumLength = Math.min(metrics.heightPixels, metrics.widthPixels); + final float captionRatioInverse = 20f + 4f * (1.0f - captionScale); + binding.subtitleView.setFixedTextSize( + TypedValue.COMPLEX_UNIT_PX, minimumLength / captionRatioInverse); + } + //endregion + + + /*////////////////////////////////////////////////////////////////////////// + // Gestures + //////////////////////////////////////////////////////////////////////////*/ + //region Gestures + + @SuppressWarnings("checkstyle:ParameterNumber") + @Override + public void onLayoutChange(final View view, final int l, final int t, final int r, final int b, + final int ol, final int ot, final int or, final int ob) { + if (l != ol || t != ot || r != or || b != ob) { + // Use a smaller value to be consistent across screen orientations, and to make usage + // easier. Multiply by 3/4 to ensure the user does not need to move the finger up to the + // screen border, in order to reach the maximum volume/brightness. + final int width = r - l; + final int height = b - t; + final int min = Math.min(width, height); + final int maxGestureLength = (int) (min * 0.75); + + if (DEBUG) { + Log.d(TAG, "maxGestureLength = " + maxGestureLength); + } + + binding.volumeProgressBar.setMax(maxGestureLength); + binding.brightnessProgressBar.setMax(maxGestureLength); + + setInitialGestureValues(); + binding.itemsListPanel.getLayoutParams().height = + height - binding.itemsListPanel.getTop(); + } + } + + private void setInitialGestureValues() { + if (player.getAudioReactor() != null) { + final float currentVolumeNormalized = (float) player.getAudioReactor().getVolume() + / player.getAudioReactor().getMaxVolume(); + binding.volumeProgressBar.setProgress( + (int) (binding.volumeProgressBar.getMax() * currentVolumeNormalized)); + } + } + //endregion + + + /*////////////////////////////////////////////////////////////////////////// + // Play queue, segments and streams + //////////////////////////////////////////////////////////////////////////*/ + //region Play queue, segments and streams + + @Override + public void onMetadataChanged(@NonNull final StreamInfo info) { + super.onMetadataChanged(info); + showHideKodiButton(); + if (areSegmentsVisible) { + if (segmentAdapter.setItems(info)) { + final int adapterPosition = getNearestStreamSegmentPosition( + player.getExoPlayer().getCurrentPosition()); + segmentAdapter.selectSegmentAt(adapterPosition); + binding.itemsList.scrollToPosition(adapterPosition); + } else { + closeItemsList(); + } + } + } + + @Override + public void onPlayQueueEdited() { + super.onPlayQueueEdited(); + showOrHideButtons(); + } + + private void onQueueClicked() { + isQueueVisible = true; + + hideSystemUIIfNeeded(); + buildQueue(); + + binding.itemsListHeaderTitle.setVisibility(View.GONE); + binding.itemsListHeaderDuration.setVisibility(View.VISIBLE); + binding.shuffleButton.setVisibility(View.VISIBLE); + binding.repeatButton.setVisibility(View.VISIBLE); + binding.addToPlaylistButton.setVisibility(View.VISIBLE); + + hideControls(0, 0); + binding.itemsListPanel.requestFocus(); + animate(binding.itemsListPanel, true, DEFAULT_CONTROLS_DURATION, + AnimationType.SLIDE_AND_ALPHA); + + @Nullable final PlayQueue playQueue = player.getPlayQueue(); + if (playQueue != null) { + binding.itemsList.scrollToPosition(playQueue.getIndex()); + } + + updateQueueTime((int) player.getExoPlayer().getCurrentPosition()); + } + + private void buildQueue() { + binding.itemsList.setAdapter(playQueueAdapter); + binding.itemsList.setClickable(true); + binding.itemsList.setLongClickable(true); + + binding.itemsList.clearOnScrollListeners(); + binding.itemsList.addOnScrollListener(getQueueScrollListener()); + + itemTouchHelper = new ItemTouchHelper(getItemTouchCallback()); + itemTouchHelper.attachToRecyclerView(binding.itemsList); + + playQueueAdapter.setSelectedListener(getOnSelectedListener()); + + binding.itemsListClose.setOnClickListener(view -> closeItemsList()); + } + + private void onSegmentsClicked() { + areSegmentsVisible = true; + + hideSystemUIIfNeeded(); + buildSegments(); + + binding.itemsListHeaderTitle.setVisibility(View.VISIBLE); + binding.itemsListHeaderDuration.setVisibility(View.GONE); + binding.shuffleButton.setVisibility(View.GONE); + binding.repeatButton.setVisibility(View.GONE); + binding.addToPlaylistButton.setVisibility(View.GONE); + + hideControls(0, 0); + binding.itemsListPanel.requestFocus(); + animate(binding.itemsListPanel, true, DEFAULT_CONTROLS_DURATION, + AnimationType.SLIDE_AND_ALPHA); + + final int adapterPosition = getNearestStreamSegmentPosition( + player.getExoPlayer().getCurrentPosition()); + segmentAdapter.selectSegmentAt(adapterPosition); + binding.itemsList.scrollToPosition(adapterPosition); + } + + private void buildSegments() { + binding.itemsList.setAdapter(segmentAdapter); + binding.itemsList.setClickable(true); + binding.itemsList.setLongClickable(true); + + binding.itemsList.clearOnScrollListeners(); + if (itemTouchHelper != null) { + itemTouchHelper.attachToRecyclerView(null); + } + + player.getCurrentStreamInfo().ifPresent(segmentAdapter::setItems); + + binding.shuffleButton.setVisibility(View.GONE); + binding.repeatButton.setVisibility(View.GONE); + binding.addToPlaylistButton.setVisibility(View.GONE); + binding.itemsListClose.setOnClickListener(view -> closeItemsList()); + } + + public void closeItemsList() { + if (isQueueVisible || areSegmentsVisible) { + isQueueVisible = false; + areSegmentsVisible = false; + + if (itemTouchHelper != null) { + itemTouchHelper.attachToRecyclerView(null); + } + + animate(binding.itemsListPanel, false, DEFAULT_CONTROLS_DURATION, + AnimationType.SLIDE_AND_ALPHA, 0, () -> + // Even when queueLayout is GONE it receives touch events + // and ruins normal behavior of the app. This line fixes it + binding.itemsListPanel.setTranslationY( + -binding.itemsListPanel.getHeight() * 5.0f)); + + // clear focus, otherwise a white rectangle remains on top of the player + binding.itemsListClose.clearFocus(); + binding.playPauseButton.requestFocus(); + } + } + + private OnScrollBelowItemsListener getQueueScrollListener() { + return new OnScrollBelowItemsListener() { + @Override + public void onScrolledDown(final RecyclerView recyclerView) { + @Nullable final PlayQueue playQueue = player.getPlayQueue(); + if (playQueue != null && !playQueue.isComplete()) { + playQueue.fetch(); + } else if (binding != null) { + binding.itemsList.clearOnScrollListeners(); + } + } + }; + } + + private StreamSegmentAdapter.StreamSegmentListener getStreamSegmentListener() { + return new StreamSegmentAdapter.StreamSegmentListener() { + @Override + public void onItemClick(@NonNull final StreamSegmentItem item, final int seconds) { + segmentAdapter.selectSegment(item); + player.seekTo(seconds * 1000L); + player.triggerProgressUpdate(); + } + + @Override + public void onItemLongClick(@NonNull final StreamSegmentItem item, final int seconds) { + @Nullable final MediaItemTag currentMetadata = player.getCurrentMetadata(); + if (currentMetadata == null + || currentMetadata.getServiceId() != YouTube.getServiceId()) { + return; + } + + final PlayQueueItem currentItem = player.getCurrentItem(); + if (currentItem != null) { + String videoUrl = player.getVideoUrl(); + videoUrl += ("&t=" + seconds); + ShareUtils.shareText(context, currentItem.getTitle(), + videoUrl, currentItem.getThumbnails()); + } + } + }; + } + + private int getNearestStreamSegmentPosition(final long playbackPosition) { + int nearestPosition = 0; + final List segments = player.getCurrentStreamInfo() + .map(StreamInfo::getStreamSegments) + .orElse(Collections.emptyList()); + + for (int i = 0; i < segments.size(); i++) { + if (segments.get(i).getStartTimeSeconds() * 1000L > playbackPosition) { + break; + } + nearestPosition++; + } + return Math.max(0, nearestPosition - 1); + } + + private ItemTouchHelper.SimpleCallback getItemTouchCallback() { + return new PlayQueueItemTouchCallback() { + @Override + public void onMove(final int sourceIndex, final int targetIndex) { + @Nullable final PlayQueue playQueue = player.getPlayQueue(); + if (playQueue != null) { + playQueue.move(sourceIndex, targetIndex); + } + } + + @Override + public void onSwiped(final int index) { + @Nullable final PlayQueue playQueue = player.getPlayQueue(); + if (playQueue != null && index != -1) { + playQueue.remove(index); + } + } + }; + } + + private PlayQueueItemBuilder.OnSelectedListener getOnSelectedListener() { + return new PlayQueueItemBuilder.OnSelectedListener() { + @Override + public void selected(final PlayQueueItem item, final View view) { + player.selectQueueItem(item); + } + + @Override + public void held(final PlayQueueItem item, final View view) { + @Nullable final PlayQueue playQueue = player.getPlayQueue(); + @Nullable final AppCompatActivity parentActivity = getParentActivity().orElse(null); + if (playQueue != null && parentActivity != null && playQueue.indexOf(item) != -1) { + openPopupMenu(player.getPlayQueue(), item, view, true, + parentActivity.getSupportFragmentManager(), context); + } + } + + @Override + public void onStartDrag(final PlayQueueItemHolder viewHolder) { + if (itemTouchHelper != null) { + itemTouchHelper.startDrag(viewHolder); + } + } + }; + } + + private void updateQueueTime(final int currentTime) { + @Nullable final PlayQueue playQueue = player.getPlayQueue(); + if (playQueue == null) { + return; + } + + final int currentStream = playQueue.getIndex(); + int before = 0; + int after = 0; + + final List streams = playQueue.getStreams(); + final int nStreams = streams.size(); + + for (int i = 0; i < nStreams; i++) { + if (i < currentStream) { + before += streams.get(i).getDuration(); + } else { + after += streams.get(i).getDuration(); + } + } + + before *= 1000; + after *= 1000; + + binding.itemsListHeaderDuration.setText( + String.format("%s/%s", + getTimeString(currentTime + before), + getTimeString(before + after) + )); + } + + @Override + protected boolean isAnyListViewOpen() { + return isQueueVisible || areSegmentsVisible; + } + + @Override + public boolean isFullscreen() { + return isFullscreen; + } + + public boolean isVerticalVideo() { + return isVerticalVideo; + } + + //endregion + + + /*////////////////////////////////////////////////////////////////////////// + // Click listeners + //////////////////////////////////////////////////////////////////////////*/ + //region Click listeners + + @Override + protected void onPlaybackSpeedClicked() { + getParentActivity().ifPresent(activity -> + PlaybackParameterDialog.newInstance(player.getPlaybackSpeed(), + player.getPlaybackPitch(), player.getPlaybackSkipSilence(), + player::setPlaybackParameters) + .show(activity.getSupportFragmentManager(), null)); + } + + @Override + public boolean onKeyDown(final int keyCode) { + if (keyCode == KeyEvent.KEYCODE_SPACE && isFullscreen) { + player.playPause(); + if (player.isPlaying()) { + hideControls(0, 0); + } + return true; + } + return super.onKeyDown(keyCode); + } + //endregion + + + /*////////////////////////////////////////////////////////////////////////// + // Video size, orientation, fullscreen + //////////////////////////////////////////////////////////////////////////*/ + //region Video size, orientation, fullscreen + + private void setupScreenRotationButton() { + binding.screenRotationButton.setVisibility(globalScreenOrientationLocked(context) + || isVerticalVideo || DeviceUtils.isTablet(context) + ? View.VISIBLE : View.GONE); + binding.screenRotationButton.setImageDrawable(AppCompatResources.getDrawable(context, + isFullscreen ? R.drawable.ic_fullscreen_exit + : R.drawable.ic_fullscreen)); + } + + @Override + public void onVideoSizeChanged(@NonNull final VideoSize videoSize) { + super.onVideoSizeChanged(videoSize); + isVerticalVideo = videoSize.width < videoSize.height; + + if (globalScreenOrientationLocked(context) + && isFullscreen + && isLandscape() == isVerticalVideo + && !DeviceUtils.isTv(context) + && !DeviceUtils.isTablet(context)) { + // set correct orientation + player.getFragmentListener().ifPresent( + PlayerServiceEventListener::onScreenRotationButtonClicked); + } + + setupScreenRotationButton(); + } + + public void toggleFullscreen() { + if (DEBUG) { + Log.d(TAG, "toggleFullscreen() called"); + } + final PlayerServiceEventListener fragmentListener = player.getFragmentListener() + .orElse(null); + if (fragmentListener == null || player.exoPlayerIsNull()) { + return; + } + + isFullscreen = !isFullscreen; + if (isFullscreen) { + // Android needs tens milliseconds to send new insets but a user is able to see + // how controls changes it's position from `0` to `nav bar height` padding. + // So just hide the controls to hide this visual inconsistency + hideControls(0, 0); + } else { + // Apply window insets because Android will not do it when orientation changes + // from landscape to portrait (open vertical video to reproduce) + binding.playbackControlRoot.setPadding(0, 0, 0, 0); + } + fragmentListener.onFullscreenStateChanged(isFullscreen); + + binding.titleTextView.setVisibility(isFullscreen ? View.VISIBLE : View.GONE); + binding.channelTextView.setVisibility(isFullscreen ? View.VISIBLE : View.GONE); + binding.playerCloseButton.setVisibility(isFullscreen ? View.GONE : View.VISIBLE); + setupScreenRotationButton(); + } + + public void checkLandscape() { + // check if landscape is correct + final boolean videoInLandscapeButNotInFullscreen = isLandscape() + && !isFullscreen + && !player.isAudioOnly(); + final boolean notPaused = player.getCurrentState() != STATE_COMPLETED + && player.getCurrentState() != STATE_PAUSED; + + if (videoInLandscapeButNotInFullscreen + && notPaused + && !DeviceUtils.isTablet(context)) { + toggleFullscreen(); + } + } + //endregion + + /*////////////////////////////////////////////////////////////////////////// + // Getters + //////////////////////////////////////////////////////////////////////////*/ + //region Getters + + private Optional getParentContext() { + return Optional.ofNullable(binding.getRoot().getParent()) + .filter(ViewGroup.class::isInstance) + .map(parent -> ((ViewGroup) parent).getContext()); + } + + public Optional getParentActivity() { + return getParentContext() + .filter(AppCompatActivity.class::isInstance) + .map(AppCompatActivity.class::cast); + } + + public boolean isLandscape() { + // DisplayMetrics from activity context knows about MultiWindow feature + // while DisplayMetrics from app context doesn't + return DeviceUtils.isLandscape(getParentContext().orElse(player.getService())); + } + //endregion +} diff --git a/app/src/braveLegacy/java/org/schabi/newpipe/settings/BraveVideoAudioSettingsBaseFragment.java b/app/src/braveLegacy/java/org/schabi/newpipe/settings/BraveVideoAudioSettingsBaseFragment.java new file mode 100644 index 0000000000..ebcf40859b --- /dev/null +++ b/app/src/braveLegacy/java/org/schabi/newpipe/settings/BraveVideoAudioSettingsBaseFragment.java @@ -0,0 +1,70 @@ +package org.schabi.newpipe.settings; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Build; + +import org.schabi.newpipe.R; + +import androidx.preference.ListPreference; +import androidx.preference.PreferenceManager; + + +public abstract class BraveVideoAudioSettingsBaseFragment extends BraveBasePreferenceFragment { + + public static void makeConfigOptionsSuitableForFlavor(final Context context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + return; + } + + final SharedPreferences prefs = + PreferenceManager.getDefaultSharedPreferences(context); + + // make sure seekbar thumbnail stuff is *not* set to high quality as it + // consumes to much memory which gives OutOfMemoryError Exception on Kitkat + // -> so default to low quality + // -> TODO long run fix the bug of the seekbar_preview_thumbnail implementation + final String seekBarOption = prefs.getString(context.getString( + R.string.seekbar_preview_thumbnail_key), null + ); + if ((null == seekBarOption) || (seekBarOption.equals( + context.getString(R.string.seekbar_preview_thumbnail_high_quality)))) { + prefs.edit().putString( + context.getString(R.string.seekbar_preview_thumbnail_key), + context.getString(R.string.seekbar_preview_thumbnail_low_quality)).apply(); + } + } + + @Override + protected void manipulateCreatedPreferenceOptions() { + super.manipulateCreatedPreferenceOptions(); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + return; + } + + setListPreferenceData(); + } + + private void setListPreferenceData() { + final ListPreference lp = (ListPreference) findPreference( + getString(R.string.seekbar_preview_thumbnail_key)); + + final CharSequence[] entries = { + getString(R.string.low_quality_smaller), + getString(R.string.dont_show) + }; + + final CharSequence[] entryValues = { + getString(R.string.seekbar_preview_thumbnail_low_quality), + getString(R.string.seekbar_preview_thumbnail_none) + }; + + lp.setEntries(entries); + lp.setEntryValues(entryValues); + // default value has to be set in BraveApp via the static + // method makeConfigOptionsSuitableForFlavor() + //lp.setDefaultValue(getString(R.string.seekbar_preview_thumbnail_low_quality)); + //lp.setValueIndex(0); + } +} diff --git a/app/src/braveLegacy/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java b/app/src/braveLegacy/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java new file mode 100644 index 0000000000..fc8783b8c3 --- /dev/null +++ b/app/src/braveLegacy/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java @@ -0,0 +1,289 @@ +package org.schabi.newpipe.settings; + +import static org.schabi.newpipe.extractor.utils.Utils.decodeUrlUtf8; +import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; + +import android.app.Activity; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.util.Log; + +import androidx.activity.result.ActivityResult; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult; +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; +import androidx.appcompat.app.AlertDialog; +import androidx.preference.Preference; +import androidx.preference.SwitchPreferenceCompat; + +import com.nononsenseapps.filepicker.Utils; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard; +import org.schabi.newpipe.streams.io.StoredDirectoryHelper; +import org.schabi.newpipe.util.FilePickerActivityHelper; + +import java.io.File; +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.URI; + +public class DownloadSettingsFragment extends BasePreferenceFragment { + public static final boolean IGNORE_RELEASE_ON_OLD_PATH = true; + private String downloadPathVideoPreference; + private String downloadPathAudioPreference; + private String storageUseSafPreference; + + private Preference prefPathVideo; + private Preference prefPathAudio; + private Preference prefStorageAsk; + + private Context ctx; + private final ActivityResultLauncher requestDownloadVideoPathLauncher = + registerForActivityResult( + new StartActivityForResult(), this::requestDownloadVideoPathResult); + private final ActivityResultLauncher requestDownloadAudioPathLauncher = + registerForActivityResult( + new StartActivityForResult(), this::requestDownloadAudioPathResult); + + @Override + public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { + addPreferencesFromResourceRegistry(); + + downloadPathVideoPreference = getString(R.string.download_path_video_key); + downloadPathAudioPreference = getString(R.string.download_path_audio_key); + storageUseSafPreference = getString(R.string.storage_use_saf); + final String downloadStorageAsk = getString(R.string.downloads_storage_ask); + + prefPathVideo = findPreference(downloadPathVideoPreference); + 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 (hasInvalidPath(downloadPathVideoPreference) + || hasInvalidPath(downloadPathAudioPreference)) { + updatePreferencesSummary(); + } + + prefStorageAsk.setOnPreferenceChangeListener((preference, value) -> { + updatePathPickers(!(boolean) value); + return true; + }); + } + + @Override + public void onAttach(@NonNull final Context context) { + super.onAttach(context); + ctx = context; + } + + @Override + public void onDetach() { + super.onDetach(); + ctx = null; + prefStorageAsk.setOnPreferenceChangeListener(null); + } + + private void updatePreferencesSummary() { + showPathInSummary(downloadPathVideoPreference, R.string.download_path_summary, + prefPathVideo); + showPathInSummary(downloadPathAudioPreference, R.string.download_path_audio_summary, + prefPathAudio); + } + + private void showPathInSummary(final String prefKey, @StringRes final int defaultString, + final Preference target) { + String rawUri = defaultPreferences.getString(prefKey, null); + if (rawUri == null || rawUri.isEmpty()) { + target.setSummary(getString(defaultString)); + return; + } + + if (rawUri.charAt(0) == File.separatorChar) { + target.setSummary(rawUri); + return; + } + if (rawUri.startsWith(ContentResolver.SCHEME_FILE)) { + target.setSummary(new File(URI.create(rawUri)).getPath()); + return; + } + + try { + rawUri = decodeUrlUtf8(rawUri); + } catch (final UnsupportedEncodingException e) { + // nothing to do + } + + target.setSummary(rawUri); + } + + private boolean isFileUri(final String path) { + return path.charAt(0) == File.separatorChar || path.startsWith(ContentResolver.SCHEME_FILE); + } + + private boolean hasInvalidPath(final String prefKey) { + final String value = defaultPreferences.getString(prefKey, null); + return value == null || value.isEmpty(); + } + + private void updatePathPickers(final boolean enabled) { + prefPathVideo.setEnabled(enabled); + prefPathAudio.setEnabled(enabled); + } + + // FIXME: after releasing the old path, all downloads created on the folder becomes inaccessible + private void forgetSAFTree(final Context context, final String oldPath) { + if (IGNORE_RELEASE_ON_OLD_PATH) { + return; + } + + if (oldPath == null || oldPath.isEmpty() || isFileUri(oldPath)) { + return; + } + + try { + final Uri uri = Uri.parse(oldPath); + + context.getContentResolver() + .releasePersistableUriPermission(uri, StoredDirectoryHelper.PERMISSION_FLAGS); + context.revokeUriPermission(uri, StoredDirectoryHelper.PERMISSION_FLAGS); + + Log.i(TAG, "Revoke old path permissions success on " + oldPath); + } catch (final Exception err) { + Log.e(TAG, "Error revoking old path permissions on " + oldPath, err); + } + } + + private void showMessageDialog(@StringRes final int title, @StringRes final int message) { + new AlertDialog.Builder(ctx) + .setTitle(title) + .setMessage(message) + .setPositiveButton(getString(R.string.ok), null) + .show(); + } + + @Override + public boolean onPreferenceTreeClick(@NonNull final Preference preference) { + if (DEBUG) { + Log.d(TAG, "onPreferenceTreeClick() called with: " + + "preference = [" + preference + "]"); + } + + final String key = preference.getKey(); + + if (key.equals(storageUseSafPreference)) { + 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)) { + launchDirectoryPicker(requestDownloadVideoPathLauncher); + } else if (key.equals(downloadPathAudioPreference)) { + launchDirectoryPicker(requestDownloadAudioPathLauncher); + } else { + return super.onPreferenceTreeClick(preference); + } + + return true; + } + + private void launchDirectoryPicker(final ActivityResultLauncher launcher) { + NoFileManagerSafeGuard.launchSafe( + launcher, + StoredDirectoryHelper.getPicker(ctx), + TAG, + ctx + ); + } + + private void requestDownloadVideoPathResult(final ActivityResult result) { + requestDownloadPathResult(result, downloadPathVideoPreference); + } + + private void requestDownloadAudioPathResult(final ActivityResult result) { + requestDownloadPathResult(result, downloadPathAudioPreference); + } + + private void requestDownloadPathResult(final ActivityResult result, final String key) { + assureCorrectAppLanguage(getContext()); + + if (result.getResultCode() != Activity.RESULT_OK) { + return; + } + + Uri uri = null; + if (result.getData() != null) { + uri = result.getData().getData(); + } + if (uri == null) { + showMessageDialog(R.string.general_error, R.string.invalid_directory); + return; + } + + + // revoke permissions on the old save path (required for SAF only) + final Context context = requireContext(); + + forgetSAFTree(context, defaultPreferences.getString(key, "")); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP + && !FilePickerActivityHelper.isOwnFileUri(context, uri)) { + // steps to acquire the selected path: + // 1. acquire permissions on the new save path + // 2. save the new path, if step(2) was successful + try { + context.grantUriPermission(context.getPackageName(), uri, + StoredDirectoryHelper.PERMISSION_FLAGS); + + final StoredDirectoryHelper mainStorage = + new StoredDirectoryHelper(context, uri, null); + Log.i(TAG, "Acquiring tree success from " + uri.toString()); + + if (!mainStorage.canWrite()) { + throw new IOException("No write permissions on " + uri.toString()); + } + } catch (final IOException err) { + Log.e(TAG, "Error acquiring tree from " + uri.toString(), err); + showMessageDialog(R.string.general_error, R.string.no_available_dir); + return; + } + } else { + final File target = Utils.getFileForUri(uri); + if (!target.canWrite()) { + showMessageDialog(R.string.download_to_sdcard_error_title, + R.string.download_to_sdcard_error_message); + return; + } + uri = Uri.fromFile(target); + } + + defaultPreferences.edit().putString(key, uri.toString()).apply(); + updatePreferencesSummary(); + } +} diff --git a/app/src/braveLegacy/java/org/schabi/newpipe/settings/NewPipeSettings.java b/app/src/braveLegacy/java/org/schabi/newpipe/settings/NewPipeSettings.java new file mode 100644 index 0000000000..45dbf61ca0 --- /dev/null +++ b/app/src/braveLegacy/java/org/schabi/newpipe/settings/NewPipeSettings.java @@ -0,0 +1,188 @@ +package org.schabi.newpipe.settings; + +import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Build; +import android.os.Environment; + +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; +import androidx.preference.PreferenceManager; + +import org.schabi.newpipe.App; +import org.schabi.newpipe.R; +import org.schabi.newpipe.util.DeviceUtils; + +import java.io.File; +import java.util.Set; + +/* + * Created by k3b on 07.01.2016. + * + * Copyright (C) Christian Schabesberger 2015 + * NewPipeSettings.java is part of NewPipe. + * + * NewPipe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * NewPipe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with NewPipe. If not, see . + */ + +/** + * Helper class for global settings. + */ +public final class NewPipeSettings { + private NewPipeSettings() { } + + public static void initSettings(final Context context) { + // first run migrations, then setDefaultValues, since the latter requires the correct types + SettingMigrations.runMigrationsIfNeeded(context); + + // readAgain is true so that if new settings are added their default value is set + PreferenceManager.setDefaultValues(context, R.xml.main_settings, true); + PreferenceManager.setDefaultValues(context, R.xml.video_audio_settings, true); + PreferenceManager.setDefaultValues(context, R.xml.download_settings, true); + PreferenceManager.setDefaultValues(context, R.xml.appearance_settings, true); + PreferenceManager.setDefaultValues(context, R.xml.history_settings, true); + PreferenceManager.setDefaultValues(context, R.xml.content_settings, true); + PreferenceManager.setDefaultValues(context, R.xml.player_notification_settings, true); + PreferenceManager.setDefaultValues(context, R.xml.update_settings, true); + PreferenceManager.setDefaultValues(context, R.xml.debug_settings, true); + PreferenceManager.setDefaultValues(context, R.xml.sponsor_block_category_settings, true); + PreferenceManager.setDefaultValues(context, R.xml.backup_restore_settings, true); + + saveDefaultVideoDownloadDirectory(context); + saveDefaultAudioDownloadDirectory(context); + + disableMediaTunnelingIfNecessary(context); + } + + static void saveDefaultVideoDownloadDirectory(final Context context) { + saveDefaultDirectory(context, R.string.download_path_video_key, + Environment.DIRECTORY_MOVIES); + } + + static void saveDefaultAudioDownloadDirectory(final Context context) { + saveDefaultDirectory(context, R.string.download_path_audio_key, + Environment.DIRECTORY_MUSIC); + } + + 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(); + } + } + + @NonNull + public static File getDir(final String defaultDirectoryName) { + return new File(Environment.getExternalStorageDirectory(), defaultDirectoryName); + } + + private static String getNewPipeChildFolderPathForDir(final File dir) { + return new File(dir, "NewPipe").toURI().toString(); + } + + 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, true); + } + + private static boolean showSearchSuggestions(final Context context, + final SharedPreferences sharedPreferences, + @StringRes final int key) { + final Set enabledSearchSuggestions = sharedPreferences.getStringSet( + context.getString(R.string.show_search_suggestions_key), null); + + if (enabledSearchSuggestions == null) { + return true; // defaults to true + } else { + return enabledSearchSuggestions.contains(context.getString(key)); + } + } + + public static boolean showLocalSearchSuggestions(final Context context, + final SharedPreferences sharedPreferences) { + return showSearchSuggestions(context, sharedPreferences, + R.string.show_local_search_suggestions_key); + } + + public static boolean showRemoteSearchSuggestions(final Context context, + final SharedPreferences sharedPreferences) { + return showSearchSuggestions(context, sharedPreferences, + R.string.show_remote_search_suggestions_key); + } + + private static void disableMediaTunnelingIfNecessary(@NonNull final Context context) { + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + final String disabledTunnelingKey = context.getString(R.string.disable_media_tunneling_key); + final String disabledTunnelingAutomaticallyKey = + context.getString(R.string.disabled_media_tunneling_automatically_key); + final String blacklistVersionKey = + context.getString(R.string.media_tunneling_device_blacklist_version); + + final int lastMediaTunnelingUpdate = prefs.getInt(blacklistVersionKey, 0); + final boolean wasDeviceBlacklistUpdated = + DeviceUtils.MEDIA_TUNNELING_DEVICE_BLACKLIST_VERSION != lastMediaTunnelingUpdate; + final boolean wasMediaTunnelingEnabledByUser = + prefs.getInt(disabledTunnelingAutomaticallyKey, -1) == 0 + && !prefs.getBoolean(disabledTunnelingKey, false); + + if (App.getApp().isFirstRun() + || (wasDeviceBlacklistUpdated && !wasMediaTunnelingEnabledByUser)) { + setMediaTunneling(context); + } + } + + /** + * Check if device does not support media tunneling + * and disable that exoplayer feature if necessary. + * @see DeviceUtils#shouldSupportMediaTunneling() + * @param context + */ + public static void setMediaTunneling(@NonNull final Context context) { + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + if (!DeviceUtils.shouldSupportMediaTunneling()) { + prefs.edit() + .putBoolean(context.getString(R.string.disable_media_tunneling_key), true) + .putInt(context.getString( + R.string.disabled_media_tunneling_automatically_key), 1) + .putInt(context.getString(R.string.media_tunneling_device_blacklist_version), + DeviceUtils.MEDIA_TUNNELING_DEVICE_BLACKLIST_VERSION) + .apply(); + } else { + prefs.edit() + .putInt(context.getString(R.string.media_tunneling_device_blacklist_version), + DeviceUtils.MEDIA_TUNNELING_DEVICE_BLACKLIST_VERSION).apply(); + } + } +} diff --git a/app/src/braveLegacy/java/org/schabi/newpipe/settings/NotificationSettingsFragment.kt b/app/src/braveLegacy/java/org/schabi/newpipe/settings/NotificationSettingsFragment.kt new file mode 100644 index 0000000000..6bea8b69e3 --- /dev/null +++ b/app/src/braveLegacy/java/org/schabi/newpipe/settings/NotificationSettingsFragment.kt @@ -0,0 +1,19 @@ +package org.schabi.newpipe.settings + +import android.os.Build +import android.os.Bundle +import androidx.preference.Preference +import org.schabi.newpipe.R + +class NotificationSettingsFragment : BasePreferenceFragment() { + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResourceRegistry() + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + val colorizePref: Preference? = findPreference(getString(R.string.notification_colorize_key)) + colorizePref?.let { + preferenceScreen.removePreference(it) + } + } + } +} diff --git a/app/src/braveLegacy/java/org/schabi/newpipe/settings/PlayerNotificationSettingsFragment.kt b/app/src/braveLegacy/java/org/schabi/newpipe/settings/PlayerNotificationSettingsFragment.kt new file mode 100644 index 0000000000..3549bff42e --- /dev/null +++ b/app/src/braveLegacy/java/org/schabi/newpipe/settings/PlayerNotificationSettingsFragment.kt @@ -0,0 +1,19 @@ +package org.schabi.newpipe.settings + +import android.os.Build +import android.os.Bundle +import androidx.preference.Preference +import org.schabi.newpipe.R + +class PlayerNotificationSettingsFragment : BasePreferenceFragment() { + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResourceRegistry() + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + val colorizePref: Preference? = findPreference(getString(R.string.notification_colorize_key)) + colorizePref?.let { + preferenceScreen.removePreference(it) + } + } + } +} diff --git a/app/src/braveLegacy/java/org/schabi/newpipe/settings/SettingMigrations.java b/app/src/braveLegacy/java/org/schabi/newpipe/settings/SettingMigrations.java new file mode 100644 index 0000000000..ce270a2cf1 --- /dev/null +++ b/app/src/braveLegacy/java/org/schabi/newpipe/settings/SettingMigrations.java @@ -0,0 +1,237 @@ +package org.schabi.newpipe.settings; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Build; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.preference.PreferenceManager; + +import org.schabi.newpipe.App; +import org.schabi.newpipe.R; +import org.schabi.newpipe.error.ErrorInfo; +import org.schabi.newpipe.error.ErrorUtil; +import org.schabi.newpipe.error.UserAction; +import org.schabi.newpipe.util.DeviceUtils; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import static org.schabi.newpipe.MainActivity.DEBUG; + +/** + * In order to add a migration, follow these steps, given P is the previous version:
+ * - in the class body add a new {@code MIGRATION_P_P+1 = new Migration(P, P+1) { ... }} and put in + * the {@code migrate()} method the code that need to be run when migrating from P to P+1
+ * - add {@code MIGRATION_P_P+1} at the end of {@link SettingMigrations#SETTING_MIGRATIONS}
+ * - increment {@link SettingMigrations#VERSION}'s value by 1 (so it should become P+1) + */ +public final class SettingMigrations { + + private static final String TAG = SettingMigrations.class.toString(); + private static SharedPreferences sp; + + private static final Migration MIGRATION_0_1 = new Migration(0, 1) { + @Override + public void migrate(@NonNull final Context context) { + // We changed the content of the dialog which opens when sharing a link to NewPipe + // by removing the "open detail page" option. + // Therefore, show the dialog once again to ensure users need to choose again and are + // aware of the changed dialog. + final SharedPreferences.Editor editor = sp.edit(); + editor.putString(context.getString(R.string.preferred_open_action_key), + context.getString(R.string.always_ask_open_action_key)); + editor.apply(); + } + }; + + private static final Migration MIGRATION_1_2 = new Migration(1, 2) { + @Override + protected void migrate(@NonNull final Context context) { + // The new application workflow introduced in #2907 allows minimizing videos + // while playing to do other stuff within the app. + // For an even better workflow, we minimize a stream when switching the app to play in + // background. + // Therefore, set default value to background, if it has not been changed yet. + final String minimizeOnExitKey = context.getString(R.string.minimize_on_exit_key); + if (sp.getString(minimizeOnExitKey, "") + .equals(context.getString(R.string.minimize_on_exit_none_key))) { + final SharedPreferences.Editor editor = sp.edit(); + editor.putString(minimizeOnExitKey, + context.getString(R.string.minimize_on_exit_background_key)); + editor.apply(); + } + } + }; + + private static final Migration MIGRATION_2_3 = new Migration(2, 3) { + @Override + protected void migrate(@NonNull 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(); + } + }; + + private static final Migration MIGRATION_3_4 = new Migration(3, 4) { + @Override + protected void migrate(@NonNull final Context context) { + // Pull request #3546 added support for choosing the type of search suggestions to + // show, replacing the on-off switch used before, so migrate the previous user choice + + final String showSearchSuggestionsKey = + context.getString(R.string.show_search_suggestions_key); + + boolean addAllSearchSuggestionTypes; + try { + addAllSearchSuggestionTypes = sp.getBoolean(showSearchSuggestionsKey, true); + } catch (final ClassCastException e) { + // just in case it was not a boolean for some reason, let's consider it a "true" + addAllSearchSuggestionTypes = true; + } + + final Set showSearchSuggestionsValueList = new HashSet<>(); + if (addAllSearchSuggestionTypes) { + // if the preference was true, all suggestions will be shown, otherwise none + Collections.addAll(showSearchSuggestionsValueList, context.getResources() + .getStringArray(R.array.show_search_suggestions_value_list)); + } + + sp.edit().putStringSet( + showSearchSuggestionsKey, showSearchSuggestionsValueList).apply(); + } + }; + + private static final Migration MIGRATION_4_5 = new Migration(4, 5) { + @Override + protected void migrate(@NonNull final Context context) { + final boolean brightness = sp.getBoolean("brightness_gesture_control", true); + final boolean volume = sp.getBoolean("volume_gesture_control", true); + + final SharedPreferences.Editor editor = sp.edit(); + + editor.putString(context.getString(R.string.right_gesture_control_key), + context.getString(volume + ? R.string.volume_control_key : R.string.none_control_key)); + editor.putString(context.getString(R.string.left_gesture_control_key), + context.getString(brightness + ? R.string.brightness_control_key : R.string.none_control_key)); + + editor.apply(); + } + }; + + public static final Migration MIGRATION_5_6 = new Migration(5, 6) { + @Override + protected void migrate(@NonNull final Context context) { + final boolean loadImages = sp.getBoolean("download_thumbnail_key", true); + + sp.edit() + .putString(context.getString(R.string.image_quality_key), + context.getString(loadImages + ? R.string.image_quality_default + : R.string.image_quality_none_key)) + .apply(); + } + }; + + /** + * List of all implemented migrations. + *

+ * Append new migrations to the end of the list to keep it sorted ascending. + * If not sorted correctly, migrations which depend on each other, may fail. + */ + private static final Migration[] SETTING_MIGRATIONS = { + MIGRATION_0_1, + MIGRATION_1_2, + MIGRATION_2_3, + MIGRATION_3_4, + MIGRATION_4_5, + MIGRATION_5_6, + }; + + /** + * Version number for preferences. Must be incremented every time a migration is necessary. + */ + private static final int VERSION = 6; + + + public static void runMigrationsIfNeeded(@NonNull final Context context) { + // setup migrations and check if there is something to do + sp = PreferenceManager.getDefaultSharedPreferences(context); + final String lastPrefVersionKey = context.getString(R.string.last_used_preferences_version); + final int lastPrefVersion = sp.getInt(lastPrefVersionKey, 0); + + // no migration to run, already up to date + if (App.getApp().isFirstRun()) { + sp.edit().putInt(lastPrefVersionKey, VERSION).apply(); + return; + } else if (lastPrefVersion == VERSION) { + return; + } + + // run migrations + int currentVersion = lastPrefVersion; + for (final Migration currentMigration : SETTING_MIGRATIONS) { + try { + if (currentMigration.shouldMigrate(currentVersion)) { + if (DEBUG) { + Log.d(TAG, "Migrating preferences from version " + + currentVersion + " to " + currentMigration.newVersion); + } + currentMigration.migrate(context); + currentVersion = currentMigration.newVersion; + } + } catch (final Exception e) { + // save the version with the last successful migration and report the error + sp.edit().putInt(lastPrefVersionKey, currentVersion).apply(); + ErrorUtil.openActivity(context, new ErrorInfo( + e, + UserAction.PREFERENCES_MIGRATION, + "Migrating preferences from version " + lastPrefVersion + " to " + + VERSION + ". " + + "Error at " + currentVersion + " => " + ++currentVersion + )); + return; + } + } + + // store the current preferences version + sp.edit().putInt(lastPrefVersionKey, currentVersion).apply(); + } + + private SettingMigrations() { } + + abstract static class Migration { + public final int oldVersion; + public final int newVersion; + + protected Migration(final int oldVersion, final int newVersion) { + this.oldVersion = oldVersion; + this.newVersion = newVersion; + } + + /** + * @param currentVersion current settings version + * @return Returns whether this migration should be run. + * A migration is necessary if the old version of this migration is lower than or equal to + * the current settings version. + */ + private boolean shouldMigrate(final int currentVersion) { + return oldVersion >= currentVersion; + } + + protected abstract void migrate(@NonNull Context context); + + } + +} diff --git a/app/src/braveLegacy/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchResultHighlighter.java b/app/src/braveLegacy/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchResultHighlighter.java new file mode 100644 index 0000000000..418a3ea461 --- /dev/null +++ b/app/src/braveLegacy/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchResultHighlighter.java @@ -0,0 +1,127 @@ +package org.schabi.newpipe.settings.preferencesearch; + +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffColorFilter; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.RippleDrawable; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import android.util.TypedValue; + +import androidx.appcompat.content.res.AppCompatResources; +import androidx.preference.Preference; +import androidx.preference.PreferenceFragmentCompat; +import androidx.preference.PreferenceGroup; +import androidx.recyclerview.widget.RecyclerView; + +import org.schabi.newpipe.R; + + +public final class PreferenceSearchResultHighlighter { + private static final String TAG = "PrefSearchResHighlter"; + + private PreferenceSearchResultHighlighter() { + } + + /** + * Highlight the specified preference. + *
+ * Note: This function is Thread independent (can be called from outside of the main thread). + * + * @param item The item to highlight + * @param prefsFragment The fragment where the items is located on + */ + public static void highlight( + final PreferenceSearchItem item, + final PreferenceFragmentCompat prefsFragment + ) { + new Handler(Looper.getMainLooper()).post(() -> doHighlight(item, prefsFragment)); + } + + private static void doHighlight( + final PreferenceSearchItem item, + final PreferenceFragmentCompat prefsFragment + ) { + final Preference prefResult = prefsFragment.findPreference(item.getKey()); + + if (prefResult == null) { + Log.w(TAG, "Preference '" + item.getKey() + "' not found on '" + prefsFragment + "'"); + return; + } + + final RecyclerView recyclerView = prefsFragment.getListView(); + final RecyclerView.Adapter adapter = recyclerView.getAdapter(); + if (adapter instanceof PreferenceGroup.PreferencePositionCallback) { + final int position = ((PreferenceGroup.PreferencePositionCallback) adapter) + .getPreferenceAdapterPosition(prefResult); + if (position != RecyclerView.NO_POSITION) { + recyclerView.scrollToPosition(position); + recyclerView.postDelayed(() -> { + final RecyclerView.ViewHolder holder = + recyclerView.findViewHolderForAdapterPosition(position); + if (holder != null) { + final Drawable background = holder.itemView.getBackground(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP + && background instanceof RippleDrawable) { + showRippleAnimation((RippleDrawable) background); + return; + } + } + highlightFallback(prefsFragment, prefResult); + }, 200); + return; + } + } + highlightFallback(prefsFragment, prefResult); + } + + /** + * Alternative highlighting (shows an → arrow in front of the setting)if ripple does not work. + * + * @param prefsFragment + * @param prefResult + */ + private static void highlightFallback( + final PreferenceFragmentCompat prefsFragment, + final Preference prefResult + ) { + // Get primary color from text for highlight icon + final TypedValue typedValue = new TypedValue(); + final Resources.Theme theme = prefsFragment.getActivity().getTheme(); + theme.resolveAttribute(android.R.attr.textColorPrimary, typedValue, true); + final TypedArray arr = prefsFragment.getActivity() + .obtainStyledAttributes( + typedValue.data, + new int[]{android.R.attr.textColorPrimary}); + final int color = arr.getColor(0, 0xffE53935); + arr.recycle(); + + // Show highlight icon + final Drawable oldIcon = prefResult.getIcon(); + final boolean oldSpaceReserved = prefResult.isIconSpaceReserved(); + final Drawable highlightIcon = + AppCompatResources.getDrawable( + prefsFragment.requireContext(), + R.drawable.ic_play_arrow); + highlightIcon.setColorFilter(new PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)); + prefResult.setIcon(highlightIcon); + + prefsFragment.scrollToPreference(prefResult); + + new Handler(Looper.getMainLooper()).postDelayed(() -> { + prefResult.setIcon(oldIcon); + prefResult.setIconSpaceReserved(oldSpaceReserved); + }, 1000); + } + + private static void showRippleAnimation(final RippleDrawable rippleDrawable) { + rippleDrawable.setState( + new int[]{android.R.attr.state_pressed, android.R.attr.state_enabled}); + new Handler(Looper.getMainLooper()) + .postDelayed(() -> rippleDrawable.setState(new int[]{}), 1000); + } +} diff --git a/app/src/braveLegacy/java/org/schabi/newpipe/streams/io/BraveStoredDirectoryHelper.java b/app/src/braveLegacy/java/org/schabi/newpipe/streams/io/BraveStoredDirectoryHelper.java new file mode 100644 index 0000000000..071f37f5d8 --- /dev/null +++ b/app/src/braveLegacy/java/org/schabi/newpipe/streams/io/BraveStoredDirectoryHelper.java @@ -0,0 +1,57 @@ +package org.schabi.newpipe.streams.io; + +import android.os.Build; +import android.system.ErrnoException; +import android.system.Os; + +import java.io.FileDescriptor; + +import androidx.annotation.RequiresApi; + +public final class BraveStoredDirectoryHelper { + + private BraveStoredDirectoryHelper() { + + } + + public static BraveStructStatVfs statvfs(final String path) throws ErrnoException { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + return new BraveStructStatVfs(Os.statvfs(path)); + } else { + return new BraveStructStatVfs(com.github.evermindzz.osext.system.Os.statvfs(path)); + } + } + + public static BraveStructStatVfs fstatvfs(final FileDescriptor fd) throws ErrnoException { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + return new BraveStructStatVfs(Os.fstatvfs(fd)); + } else { + return new BraveStructStatVfs(com.github.evermindzz.osext.system.Os.fstatvfs(fd)); + } + } + + public static class BraveStructStatVfs { + /** + * @noinspection checkstyle:MemberName + */ + public final long f_bavail; + /** + * @noinspection checkstyle:MemberName + */ + public final long f_frsize; + + /** + * @noinspection checkstyle:ParameterName + */ + BraveStructStatVfs(final com.github.evermindzz.osext.system.StructStatVfs stat) { + this.f_bavail = stat.f_bavail; + this.f_frsize = stat.f_frsize; + } + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + BraveStructStatVfs(final android.system.StructStatVfs stat) { + this.f_bavail = stat.f_bavail; + this.f_frsize = stat.f_frsize; + } + } +} diff --git a/app/src/braveLegacy/java/org/schabi/newpipe/streams/io/StoredDirectoryHelper.java b/app/src/braveLegacy/java/org/schabi/newpipe/streams/io/StoredDirectoryHelper.java new file mode 100644 index 0000000000..0073338bf2 --- /dev/null +++ b/app/src/braveLegacy/java/org/schabi/newpipe/streams/io/StoredDirectoryHelper.java @@ -0,0 +1,405 @@ +package org.schabi.newpipe.streams.io; + +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; + +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build; +import android.os.ParcelFileDescriptor; +import android.provider.DocumentsContract; +import android.util.Log; + +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.FileDescriptor; +import java.io.IOException; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public class StoredDirectoryHelper { + private static final String TAG = StoredDirectoryHelper.class.getSimpleName(); + public static final int PERMISSION_FLAGS = Intent.FLAG_GRANT_READ_URI_PERMISSION + | Intent.FLAG_GRANT_WRITE_URI_PERMISSION; + + private Path ioTree; + private DocumentFile docTree; + + /** + * Context is `null` for non-SAF files, i.e. files that use `ioTree`. + */ + @Nullable + private Context context; + + private final String tag; + + 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())) { + ioTree = Paths.get(URI.create(path.toString())); + return; + } + + this.context = context; + + try { + this.context.getContentResolver().takePersistableUriPermission(path, PERMISSION_FLAGS); + } catch (final Exception e) { + throw new IOException(e); + } + + 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) { + throw new IOException("Failed to create the tree from Uri"); + } + } + + public StoredFileHelper createFile(final String filename, final String mime) { + return createFile(filename, mime, false); + } + + public StoredFileHelper createUniqueFile(final String name, final String mime) { + final List 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) { + try (Stream stream = Files.list(ioTree)) { + matches.addAll(stream.map(path -> path.getFileName().toString().toLowerCase()) + .filter(fileName -> fileName.startsWith(lcFileName)) + .collect(Collectors.toList())); + } catch (final IOException e) { + Log.e(TAG, "Exception while traversing " + ioTree, e); + } + } else { + // warning: SAF file listing is very slow + final Uri docTreeChildren = DocumentsContract.buildChildDocumentsUriUsingTree( + docTree.getUri(), DocumentsContract.getDocumentId(docTree.getUri())); + + 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)) { + if (cursor != null) { + while (cursor.moveToNext()) { + addIfStartWith(matches, lcFileName, cursor.getString(0)); + } + } + } + } + + if (matches.isEmpty()) { + return createFile(name, mime, true); + } + + // check if the filename is in use + String lcName = name.toLowerCase(); + for (final String testName : matches) { + if (testName.equals(lcName)) { + lcName = null; + break; + } + } + + // create file if filename not in use + 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) { + return createFile(makeFileName(filename[0], i, filename[1]), mime, true); + } + } + + return createFile(String.valueOf(System.currentTimeMillis()).concat(filename[1]), mime, + false); + } + + private StoredFileHelper createFile(final String filename, final String mime, + final boolean safe) { + final StoredFileHelper storage; + + try { + if (docTree == null) { + storage = new StoredFileHelper(ioTree, filename, mime); + } else { + storage = new StoredFileHelper(context, docTree, filename, mime, safe); + } + } catch (final IOException e) { + return null; + } + + storage.tag = tag; + + return storage; + } + + public Uri getUri() { + return docTree == null ? Uri.fromFile(ioTree.toFile()) : docTree.getUri(); + } + + public boolean exists() { + return docTree == null ? Files.exists(ioTree) : docTree.exists(); + } + + /** + * 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() { + return docTree == null; + } + + /** + * Get free memory of the storage partition this file belongs to (root of the directory). + * See StackOverflow and + * + * {@code statvfs()} and {@code fstatvfs()} docs + * + * @return amount of free memory in the volume of current directory (bytes), or {@link + * Long#MAX_VALUE} if an error occurred + */ + public long getFreeStorageSpace() { + try { + final BraveStoredDirectoryHelper.BraveStructStatVfs stat; + + if (ioTree != null) { + // non-SAF file, use statvfs with the path directly (also, `context` would be null + // for non-SAF files, so we wouldn't be able to call `getContentResolver` anyway) + stat = BraveStoredDirectoryHelper.statvfs(ioTree.toString()); + + } else { + // SAF file, we can't get a path directly, so obtain a file descriptor first + // and then use fstatvfs with the file descriptor + try (ParcelFileDescriptor parcelFileDescriptor = + context.getContentResolver().openFileDescriptor(getUri(), "r")) { + if (parcelFileDescriptor == null) { + return Long.MAX_VALUE; + } + final FileDescriptor fileDescriptor = parcelFileDescriptor.getFileDescriptor(); + stat = BraveStoredDirectoryHelper.fstatvfs(fileDescriptor); + } + } + + // this is the same formula used inside the FsStat class + return stat.f_bavail * stat.f_frsize; + } catch (final Throwable e) { + // ignore any error + Log.e(TAG, "Could not get free storage space", e); + return Long.MAX_VALUE; + } + } + + /** + * Only using Java I/O. Creates the directory named by this abstract pathname, including any + * necessary but nonexistent parent directories. + * Note that if this operation fails it may have succeeded in creating some of the necessary + * parent directories. + * + * @return true if and only if the directory was created, + * along with all necessary parent directories or already exists; false + * otherwise + */ + public boolean mkdirs() { + if (docTree == null) { + try { + Files.createDirectories(ioTree); + } catch (final IOException e) { + Log.e(TAG, "Error while creating directories at " + ioTree, e); + } + return Files.exists(ioTree); + } + + if (docTree.exists()) { + return true; + } + + try { + DocumentFile parent; + String child = docTree.getName(); + + while (true) { + parent = docTree.getParentFile(); + if (parent == null || child == null) { + break; + } + if (parent.exists()) { + return true; + } + + parent.createDirectory(child); + + child = parent.getName(); // for the next iteration + } + } catch (final Exception ignored) { + // no more parent directories or unsupported by the storage provider + } + + return false; + } + + public String getTag() { + return tag; + } + + public Uri findFile(final String filename) { + if (docTree == null) { + final Path res = ioTree.resolve(filename); + return Files.exists(res) ? Uri.fromFile(res.toFile()) : null; + } + + final DocumentFile res = findFileSAFHelper(context, docTree, filename); + return res == null ? null : res.getUri(); + } + + public boolean canWrite() { + return docTree == null ? Files.isWritable(ioTree) : 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.toFile()) : docTree.getUri()).toString(); + } + + //////////////////// + // Utils + /////////////////// + + private static void addIfStartWith(final List list, @NonNull final String base, + final String str) { + if (isNullOrEmpty(str)) { + return; + } + final String lowerStr = str.toLowerCase(); + if (lowerStr.startsWith(base)) { + list.add(lowerStr); + } + } + + /** + * Splits the filename into the name and extension. + * + * @param filename The filename to split + * @return A String array with the name at index 0 and extension at index 1 + */ + private static String[] splitFilename(@NonNull final String filename) { + final int dotIndex = filename.lastIndexOf('.'); + + 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(final String name, final int idx, final String ext) { + return name + "(" + idx + ")" + ext; + } + + /** + * 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 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 + } + + if (!tree.canRead()) { + return null; // missing read permission + } + + final int name = 0; + final int documentId = 1; + + // LOWER() SQL function is not supported + final String selection = COLUMN_DISPLAY_NAME + " = ?"; + //final String selection = COLUMN_DISPLAY_NAME + " LIKE ?%"; + + 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(); + + final String lowerFilename = filename.toLowerCase(); + + 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(lowerFilename)) { + continue; + } + + return DocumentFile.fromSingleUri(context, + DocumentsContract.buildDocumentUriUsingTree(tree.getUri(), + cursor.getString(documentId))); + } + } + + return null; + } + + public static Intent getPicker(final Context ctx) { + if (NewPipeSettings.useStorageAccessFramework(ctx)) { + return new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) + .putExtra("android.content.extra.SHOW_ADVANCED", true) + .addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + | StoredDirectoryHelper.PERMISSION_FLAGS); + } else { + return new Intent(ctx, FilePickerActivityHelper.class) + .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false) + .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, true) + .putExtra(FilePickerActivityHelper.EXTRA_MODE, + FilePickerActivityHelper.MODE_DIR); + } + } +} diff --git a/app/src/braveLegacy/java/org/schabi/newpipe/streams/io/StoredFileHelper.java b/app/src/braveLegacy/java/org/schabi/newpipe/streams/io/StoredFileHelper.java new file mode 100644 index 0000000000..ad528c7658 --- /dev/null +++ b/app/src/braveLegacy/java/org/schabi/newpipe/streams/io/StoredFileHelper.java @@ -0,0 +1,580 @@ +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 android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.documentfile.provider.DocumentFile; + +import com.nononsenseapps.filepicker.Utils; + +import org.schabi.newpipe.MainActivity; +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 java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import us.shandian.giga.io.FileStream; +import us.shandian.giga.io.FileStreamSAF; + +public class StoredFileHelper implements Serializable { + private static final boolean DEBUG = MainActivity.DEBUG; + private static final String TAG = StoredFileHelper.class.getSimpleName(); + + 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 Path ioPath; + 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)) { + final File ioFile = Utils.getFileForUri(uri); + ioPath = ioFile.toPath(); + 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 Path location, final String filename, final String mime) + throws IOException { + ioPath = location.resolve(filename); + + Files.deleteIfExists(ioPath); + Files.createFile(ioPath); + + source = Uri.fromFile(ioPath.toFile()).toString(); + sourceTree = Uri.fromFile(location.toFile()).toString(); + + srcName = ioPath.getFileName().toString(); + 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.ioPath = Paths.get(URI.create(this.source)); + } else { + final DocumentFile file = DocumentFile.fromSingleUri(context, path); + + if (file == null) { + throw new IOException("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(ioPath.toFile()); + } 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(ioPath.toFile()) : 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) { + try { + return Files.deleteIfExists(ioPath); + } catch (final IOException e) { + Log.e(TAG, "Exception while deleting " + ioPath, e); + return false; + } + } + + 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(); + + if (docFile == null) { + try { + return Files.size(ioPath); + } catch (final IOException e) { + Log.e(TAG, "Exception while getting the size of " + ioPath, e); + return 0; + } + } else { + return docFile.length(); + } + } + + public boolean canWrite() { + if (source == null) { + return false; + } + return docFile == null ? Files.isWritable(ioPath) : docFile.canWrite(); + } + + public String getName() { + if (source == null) { + return srcName; + } else if (docFile == null) { + return ioPath.getFileName().toString(); + } + + 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 || (docFile == null && ioPath == null)) { + if (DEBUG) { + Log.d(TAG, "existsAsFile called but something is null: source = [" + + (source == null ? "null => storage is invalid" : source) + + "], docFile = [" + docFile + "], ioPath = [" + ioPath + "]"); + } + return false; + } + + // WARNING: DocumentFile.exists() and DocumentFile.isFile() methods are slow + // docFile.isVirtual() means it is non-physical? + return docFile == null + ? Files.isRegularFile(ioPath) + : (docFile.exists() && docFile.isFile()); + } + + public boolean create() { + assertValid(); + final boolean result; + + if (docFile == null) { + try { + Files.createFile(ioPath); + result = true; + } catch (final IOException e) { + Log.e(TAG, "Exception while creating " + ioPath, 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(ioPath.toFile()) : 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; + ioPath = 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.ioPath.equals(storage.ioPath); + } + + 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, + @NonNull final String mimeType) { + if (NewPipeSettings.useStorageAccessFramework(ctx)) { + return new Intent(Intent.ACTION_OPEN_DOCUMENT) + .putExtra("android.content.extra.SHOW_ADVANCED", true) + .setType(mimeType) + .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, + @NonNull final String mimeType, + @Nullable final Uri initialPath) { + return applyInitialPathToPickerIntent(ctx, getPicker(ctx, mimeType), initialPath, null); + } + + public static Intent getNewPicker(@NonNull final Context ctx, + @Nullable final String filename, + @NonNull final String mimeType, + @Nullable final Uri initialPath) { + final Intent i; + if (NewPipeSettings.useStorageAccessFramework(ctx)) { + i = new Intent(Intent.ACTION_CREATE_DOCUMENT) + .putExtra("android.content.extra.SHOW_ADVANCED", true) + .setType(mimeType) + .addCategory(Intent.CATEGORY_OPENABLE) + .addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + | StoredDirectoryHelper.PERMISSION_FLAGS); + if (filename != null) { + i.putExtra(Intent.EXTRA_TITLE, filename); + } + } else { + i = new Intent(ctx, FilePickerActivityHelper.class) + .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false) + .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, true) + .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_EXISTING_FILE, true) + .putExtra(FilePickerActivityHelper.EXTRA_MODE, + FilePickerActivityHelper.MODE_NEW_FILE); + } + return applyInitialPathToPickerIntent(ctx, i, initialPath, filename); + } + + private static Intent applyInitialPathToPickerIntent(@NonNull final Context ctx, + @NonNull final Intent intent, + @Nullable final Uri initialPath, + @Nullable final String filename) { + + if (NewPipeSettings.useStorageAccessFramework(ctx)) { + if (initialPath == null) { + return intent; // nothing to do, no initial path provided + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + return intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, initialPath); + } else { + return intent; // can't set initial path on API < 26 + } + + } else { + if (initialPath == null && filename == null) { + return intent; // nothing to do, no initial path and no file name provided + } + + File file; + if (initialPath == null) { + // The only way to set the previewed filename in non-SAF FilePicker is to set a + // starting path ending with that filename. So when the initialPath is null but + // filename isn't just default to the external storage directory. + file = Environment.getExternalStorageDirectory(); + } else { + try { + file = Utils.getFileForUri(initialPath); + } catch (final Throwable ignored) { + // getFileForUri() can't decode paths to 'storage', fallback to this + file = new File(initialPath.toString()); + } + } + + // remove any filename at the end of the path (get the parent directory in that case) + if (!file.exists() || !file.isDirectory()) { + file = file.getParentFile(); + if (file == null || !file.exists()) { + // default to the external storage directory in case of an invalid path + file = Environment.getExternalStorageDirectory(); + } + // else: file is surely a directory + } + + if (filename != null) { + // append a filename so that the non-SAF FilePicker shows it as the preview + file = new File(file, filename); + } + + return intent + .putExtra(FilePickerActivityHelper.EXTRA_START_PATH, file.getAbsolutePath()); + } + } +} diff --git a/app/src/braveLegacy/java/org/schabi/newpipe/util/BraveDeviceUtils.kt b/app/src/braveLegacy/java/org/schabi/newpipe/util/BraveDeviceUtils.kt new file mode 100644 index 0000000000..06762382d7 --- /dev/null +++ b/app/src/braveLegacy/java/org/schabi/newpipe/util/BraveDeviceUtils.kt @@ -0,0 +1,12 @@ +package org.schabi.newpipe.util + +import android.os.Build +import android.view.InputDevice + +fun supportsSource(inputDevice: InputDevice, source: Int): Boolean { + return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + (inputDevice.sources and source) == source + } else { + inputDevice.supportsSource(source) + } +} diff --git a/app/src/braveLegacy/java/org/schabi/newpipe/util/BraveOkHttpTlsHelper.java b/app/src/braveLegacy/java/org/schabi/newpipe/util/BraveOkHttpTlsHelper.java new file mode 100644 index 0000000000..87e0138ff2 --- /dev/null +++ b/app/src/braveLegacy/java/org/schabi/newpipe/util/BraveOkHttpTlsHelper.java @@ -0,0 +1,63 @@ +package org.schabi.newpipe.util; + +import android.os.Build; +import android.util.Log; + +import org.schabi.newpipe.BraveTag; + +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; + +import okhttp3.OkHttpClient; + +import static org.schabi.newpipe.MainActivity.DEBUG; + +public final class BraveOkHttpTlsHelper { + + private static final String TAG = + new BraveTag().tagShort23(BraveOkHttpTlsHelper.class.getSimpleName()); + + private BraveOkHttpTlsHelper() { + } + + /** + * Enable TLS 1.3 and 1.2 on Android Kitkat. This function is mostly taken + * from the documentation of OkHttpClient.Builder.sslSocketFactory(_,_). + * + * The keystore part is inspired by https://stackoverflow.com/a/65395783/4116659 + *

+ * If there is an error, the function will safely fall back to doing nothing + * and printing the error to the console. + *

+ * + * @param builder The HTTPClient Builder on which TLS is enabled on (will be modified in-place) + * @return the same builder that was supplied. So the method can be chained. + */ + public static OkHttpClient.Builder enableModernTLS(final OkHttpClient.Builder builder) { + if (Build.VERSION.SDK_INT == Build.VERSION_CODES.KITKAT) { + try { + + final BraveTLSSocketFactory sslSocketFactory = BraveTLSSocketFactory.getInstance(); + final TrustManagerFactory trustManagerFactory = sslSocketFactory + .getTrustManagerFactory(); + + final SSLContext context = SSLContext.getInstance("TLS"); + context.init(null, trustManagerFactory.getTrustManagers(), null); + + builder.sslSocketFactory(sslSocketFactory, + (X509TrustManager) trustManagerFactory.getTrustManagers()[0]); + } catch (final KeyManagementException | NoSuchAlgorithmException e) { + if (DEBUG) { + e.printStackTrace(); + Log.e(TAG, "Unable to insert own {SSLSocket,TrustManager}Factory in OkHttp", e); + } + } + } + + return builder; + } +} diff --git a/app/src/braveLegacy/java/org/schabi/newpipe/util/BraveTLSSocketFactory.java b/app/src/braveLegacy/java/org/schabi/newpipe/util/BraveTLSSocketFactory.java new file mode 100644 index 0000000000..edbd94d47e --- /dev/null +++ b/app/src/braveLegacy/java/org/schabi/newpipe/util/BraveTLSSocketFactory.java @@ -0,0 +1,116 @@ +package org.schabi.newpipe.util; + +import android.util.Log; + +import org.schabi.newpipe.BraveTag; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.Socket; +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; + +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManagerFactory; + + +/** + * This is an extension of the SSLSocketFactory which enables TLS 1.2 and 1.3. + * Created for usage on Android 4.1-4.4 devices, which haven't enabled those by default. + */ +public final class BraveTLSSocketFactory extends SSLSocketFactory { + + private static final String TAG = + new BraveTag().tagShort23(BraveTLSSocketFactory.class.getSimpleName()); + + private static BraveTLSSocketFactory instance = null; + + private final SSLSocketFactory internalSSLSocketFactory; + private final BraveTrustManagerFactoryHelper trustManagerFactoryHelper; + + private BraveTLSSocketFactory() + throws NoSuchAlgorithmException, KeyManagementException { + trustManagerFactoryHelper = new BraveTrustManagerFactoryHelper(); + final SSLContext context = SSLContext.getInstance("TLS"); + context.init(null, trustManagerFactoryHelper.getTrustManagerFactory() + .getTrustManagers(), null); + internalSSLSocketFactory = context.getSocketFactory(); + } + + public static BraveTLSSocketFactory getInstance() + throws NoSuchAlgorithmException, KeyManagementException { + if (instance != null) { + return instance; + } + instance = new BraveTLSSocketFactory(); + return instance; + } + + public static void setAsDefault() { + try { + HttpsURLConnection.setDefaultSSLSocketFactory(getInstance()); + } catch (NoSuchAlgorithmException | KeyManagementException e) { + Log.e(TAG, "Unable to setAsDefault", e); + } + } + + @Override + public String[] getDefaultCipherSuites() { + return internalSSLSocketFactory.getDefaultCipherSuites(); + } + + @Override + public String[] getSupportedCipherSuites() { + return internalSSLSocketFactory.getSupportedCipherSuites(); + } + + @Override + public Socket createSocket() throws IOException { + return enableTLSOnSocket(internalSSLSocketFactory.createSocket()); + } + + @Override + public Socket createSocket(final Socket s, final String host, final int port, + final boolean autoClose) throws IOException { + return enableTLSOnSocket(internalSSLSocketFactory.createSocket(s, host, port, autoClose)); + } + + @Override + public Socket createSocket(final String host, final int port) throws IOException { + return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port)); + } + + @Override + public Socket createSocket(final String host, final int port, final InetAddress localHost, + final int localPort) throws IOException { + return enableTLSOnSocket(internalSSLSocketFactory.createSocket( + host, port, localHost, localPort)); + } + + @Override + public Socket createSocket(final InetAddress host, final int port) throws IOException { + return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port)); + } + + @Override + public Socket createSocket(final InetAddress address, final int port, + final InetAddress localAddress, final int localPort) + throws IOException { + return enableTLSOnSocket(internalSSLSocketFactory.createSocket( + address, port, localAddress, localPort)); + } + + private Socket enableTLSOnSocket(final Socket socket) { + if (socket instanceof SSLSocket) { + ((SSLSocket) socket).setEnabledProtocols(new String[]{"TLSv1.2", "TLSv1.3"}); + } + return socket; + } + + public TrustManagerFactory getTrustManagerFactory() { + return trustManagerFactoryHelper.getTrustManagerFactory(); + } +} diff --git a/app/src/braveLegacy/java/org/schabi/newpipe/util/BraveTrustManagerFactoryHelper.java b/app/src/braveLegacy/java/org/schabi/newpipe/util/BraveTrustManagerFactoryHelper.java new file mode 100644 index 0000000000..e2c42997e8 --- /dev/null +++ b/app/src/braveLegacy/java/org/schabi/newpipe/util/BraveTrustManagerFactoryHelper.java @@ -0,0 +1,136 @@ +package org.schabi.newpipe.util; + +import android.content.Context; +import android.util.Log; + +import org.schabi.newpipe.BraveApp; +import org.schabi.newpipe.BraveTag; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.util.Arrays; +import java.util.List; + +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; + +import static org.schabi.newpipe.MainActivity.DEBUG; + +/** + * This helper class basically init the TrustManagerFactory with our custom CA's. + *

+ * The CA's are for rumble.com and framatube.org + */ +public class BraveTrustManagerFactoryHelper { + private static final String TAG = + new BraveTag().tagShort23(BraveTrustManagerFactoryHelper.class.getSimpleName()); + TrustManagerFactory trustManagerFactory; + + public BraveTrustManagerFactoryHelper() { + + try { + final KeyStore customCAsKeystore = createKeystoreWithCustomCAsAndSystemCAs(); + trustManagerFactory = + addOurKeystoreToTrustManagerFactory(customCAsKeystore); + } catch (final NoSuchAlgorithmException | KeyStoreException + | IOException | CertificateException e) { + if (DEBUG) { + e.printStackTrace(); + Log.e(TAG, "Unable to create TrustManagerFactory with own CA's", e); + } + } + } + + public TrustManagerFactory getTrustManagerFactory() { + return trustManagerFactory; + } + + private TrustManagerFactory addOurKeystoreToTrustManagerFactory( + final KeyStore keyStore) + throws NoSuchAlgorithmException, KeyStoreException { + + final TrustManagerFactory managerFactory = TrustManagerFactory + .getInstance(TrustManagerFactory.getDefaultAlgorithm()); + + // Tell TrustManager to trust the CAs in our KeyStore + managerFactory.init(keyStore); + + // only allow one TrustManager + final TrustManager[] trustManagers = managerFactory.getTrustManagers(); + if (trustManagers.length != 1 || !(trustManagers[0] instanceof X509TrustManager)) { + throw new IllegalStateException("Unexpected default trust managers:" + + Arrays.toString(trustManagers)); + } + + return managerFactory; + } + + /** + * Add our trusted CAs for rumble.com and framatube.org to keystore. + * + * @return custom CA keystore with our added CAs + * @throws KeyStoreException + * @throws CertificateException + * @throws IOException + * @throws NoSuchAlgorithmException + */ + private KeyStore createKeystoreWithCustomCAsAndSystemCAs() + throws KeyStoreException, CertificateException, + IOException, NoSuchAlgorithmException { + + final List rawCertFiles = Arrays.asList("ca_digicert_global_g2", + "ca_lets_encrypt_root" /*, "ca_lets_encrypt"*/); + final KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + keyStore.load(null, null); + for (final String rawCertFile : rawCertFiles) { + final Certificate cert = readCertificateFromFile(rawCertFile); + keyStore.setCertificateEntry(rawCertFile, cert); + } + + addSystemCAsToKeystore(keyStore); + + return keyStore; + } + + private void addSystemCAsToKeystore( + final KeyStore keyStore) throws NoSuchAlgorithmException, KeyStoreException { + + // Default TrustManager to get device trusted CA's + final TrustManagerFactory defaultTrustManagerFactory = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + defaultTrustManagerFactory.init((KeyStore) null); + + final X509TrustManager trustManager = + (X509TrustManager) defaultTrustManagerFactory.getTrustManagers()[0]; + int idx = 0; + for (final Certificate cert : trustManager.getAcceptedIssuers()) { + keyStore.setCertificateEntry(Integer.toString(idx), cert); + idx++; + } + } + + private Certificate readCertificateFromFile( + final String rawFile) + throws IOException, CertificateException { + + final Context context = BraveApp.getAppContext(); + final InputStream inputStream = context.getResources().openRawResource( + context.getResources().getIdentifier(rawFile, + "raw", context.getPackageName())); + + final byte[] rawBytes = new byte[inputStream.available()]; + inputStream.read(rawBytes); + inputStream.close(); + + final CertificateFactory cf = CertificateFactory.getInstance("X.509"); + return cf.generateCertificate(new ByteArrayInputStream(rawBytes)); + } +} diff --git a/app/src/braveLegacy/java/org/schabi/newpipe/util/DeviceUtils.java b/app/src/braveLegacy/java/org/schabi/newpipe/util/DeviceUtils.java new file mode 100644 index 0000000000..06c0dd50d9 --- /dev/null +++ b/app/src/braveLegacy/java/org/schabi/newpipe/util/DeviceUtils.java @@ -0,0 +1,345 @@ +package org.schabi.newpipe.util; + +import static android.content.Context.INPUT_SERVICE; + +import android.annotation.SuppressLint; +import android.app.UiModeManager; +import android.content.Context; +import android.content.pm.PackageManager; +import android.content.res.Configuration; +import android.graphics.Point; +import android.hardware.input.InputManager; +import android.os.BatteryManager; +import android.os.Build; +import android.provider.Settings; +import android.util.TypedValue; +import android.view.InputDevice; +import android.view.KeyEvent; +import android.view.WindowInsets; +import android.view.WindowManager; + +import androidx.annotation.Dimension; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.content.ContextCompat; +import androidx.preference.PreferenceManager; + +import org.schabi.newpipe.App; +import org.schabi.newpipe.R; + +import java.lang.reflect.Method; + +public final class DeviceUtils { + + private static final String AMAZON_FEATURE_FIRE_TV = "amazon.hardware.fire_tv"; + private static final boolean SAMSUNG = Build.MANUFACTURER.equals("samsung"); + private static Boolean isTV = null; + private static Boolean isFireTV = null; + + /** + *

The app version code that corresponds to the last update + * of the media tunneling device blacklist.

+ *

The value of this variable needs to be updated everytime a new device that does not + * support media tunneling to match the upcoming version code.

+ * @see #shouldSupportMediaTunneling() + */ + public static final int MEDIA_TUNNELING_DEVICE_BLACKLIST_VERSION = 994; + + // region: devices not supporting media tunneling / media tunneling blacklist + /** + *

Formuler Z8 Pro, Z8, CC, Z Alpha, Z+ Neo.

+ *

Blacklist reason: black screen

+ *

Board: HiSilicon Hi3798MV200

+ */ + private static final boolean HI3798MV200 = Build.VERSION.SDK_INT == 24 + && Build.DEVICE.equals("Hi3798MV200"); + /** + *

Zephir TS43UHD-2.

+ *

Blacklist reason: black screen

+ */ + private static final boolean CVT_MT5886_EU_1G = Build.VERSION.SDK_INT == 24 + && Build.DEVICE.equals("cvt_mt5886_eu_1g"); + /** + * Hilife TV. + *

Blacklist reason: black screen

+ */ + private static final boolean REALTEKATV = Build.VERSION.SDK_INT == 25 + && Build.DEVICE.equals("RealtekATV"); + /** + *

Phillips 4K (O)LED TV.

+ * Supports custom ROMs with different API levels + */ + private static final boolean PH7M_EU_5596 = Build.VERSION.SDK_INT >= 26 + && Build.DEVICE.equals("PH7M_EU_5596"); + /** + *

Philips QM16XE.

+ *

Blacklist reason: black screen

+ */ + private static final boolean QM16XE_U = Build.VERSION.SDK_INT == 23 + && Build.DEVICE.equals("QM16XE_U"); + /** + *

Sony Bravia VH1.

+ *

Processor: MT5895

+ *

Blacklist reason: fullscreen crash / stuttering

+ */ + private static final boolean BRAVIA_VH1 = Build.VERSION.SDK_INT == 29 + && Build.DEVICE.equals("BRAVIA_VH1"); + /** + *

Sony Bravia VH2.

+ *

Blacklist reason: fullscreen crash; this includes model A90J as reported in + * + * #9023

+ */ + private static final boolean BRAVIA_VH2 = Build.VERSION.SDK_INT == 29 + && Build.DEVICE.equals("BRAVIA_VH2"); + /** + *

Sony Bravia Android TV platform 2.

+ * Uses a MediaTek MT5891 (MT5596) SoC. + * @see + * https://github.com/CiNcH83/bravia_atv2 + */ + private static final boolean BRAVIA_ATV2 = Build.DEVICE.equals("BRAVIA_ATV2"); + /** + *

Sony Bravia Android TV platform 3 4K.

+ *

Uses ARM MT5891 and a {@link #BRAVIA_ATV2} motherboard.

+ * + * @see + * https://browser.geekbench.com/v4/cpu/9101105 + */ + private static final boolean BRAVIA_ATV3_4K = Build.DEVICE.equals("BRAVIA_ATV3_4K"); + /** + *

Panasonic 4KTV-JUP.

+ *

Blacklist reason: fullscreen crash

+ */ + private static final boolean TX_50JXW834 = Build.DEVICE.equals("TX_50JXW834"); + /** + *

Bouygtel4K / Bouygues Telecom Bbox 4K.

+ *

Blacklist reason: black screen; reported at + * + * #10122

+ */ + private static final boolean HMB9213NW = Build.DEVICE.equals("HMB9213NW"); + // endregion + + 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; + } + + final PackageManager pm = App.getApp().getPackageManager(); + + // 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 + || isFireTv() + || pm.hasSystemFeature(PackageManager.FEATURE_TELEVISION); + + // from https://stackoverflow.com/a/58932366 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + final boolean isBatteryAbsent = context.getSystemService(BatteryManager.class) + .getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) == 0; + isTv = isTv || (isBatteryAbsent + && !pm.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN) + && pm.hasSystemFeature(PackageManager.FEATURE_USB_HOST) + && pm.hasSystemFeature(PackageManager.FEATURE_ETHERNET)); + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + isTv = isTv || pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK); + } + + DeviceUtils.isTV = isTv; + return DeviceUtils.isTV; + } + + /** + * Checks if the device is in desktop or DeX mode. This function should only + * be invoked once on view load as it is using reflection for the DeX checks. + * @param context the context to use for services and config. + * @return true if the Android device is in desktop mode or using DeX. + */ + @SuppressWarnings("JavaReflectionMemberAccess") + public static boolean isDesktopMode(@NonNull final Context context) { + // Adapted from https://stackoverflow.com/a/64615568 + // to check for all input devices that have an active cursor + final InputManager im = (InputManager) context.getSystemService(INPUT_SERVICE); + for (final int id : im.getInputDeviceIds()) { + final InputDevice inputDevice = im.getInputDevice(id); + + if (BraveDeviceUtilsKt.supportsSource(inputDevice, InputDevice.SOURCE_BLUETOOTH_STYLUS) + || BraveDeviceUtilsKt.supportsSource(inputDevice, InputDevice.SOURCE_MOUSE) + || BraveDeviceUtilsKt.supportsSource(inputDevice, InputDevice.SOURCE_STYLUS) + || BraveDeviceUtilsKt.supportsSource(inputDevice, InputDevice.SOURCE_TOUCHPAD) + || BraveDeviceUtilsKt.supportsSource(inputDevice, + InputDevice.SOURCE_TRACKBALL)) { + return true; + } + } + + final UiModeManager uiModeManager = + ContextCompat.getSystemService(context, UiModeManager.class); + if (uiModeManager != null + && uiModeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_DESK) { + return true; + } + + if (!SAMSUNG) { + return false; + // DeX is Samsung-specific, skip the checks below on non-Samsung devices + } + // DeX check for standalone and multi-window mode, from: + // https://developer.samsung.com/samsung-dex/modify-optimizing.html + try { + final Configuration config = context.getResources().getConfiguration(); + final Class configClass = config.getClass(); + final int semDesktopModeEnabledConst = + configClass.getField("SEM_DESKTOP_MODE_ENABLED").getInt(configClass); + final int currentMode = + configClass.getField("semDesktopModeEnabled").getInt(config); + if (semDesktopModeEnabledConst == currentMode) { + return true; + } + } catch (final NoSuchFieldException | IllegalAccessException ignored) { + // Device doesn't seem to support DeX + } + + @SuppressLint("WrongConstant") final Object desktopModeManager = context + .getApplicationContext() + .getSystemService("desktopmode"); + + if (desktopModeManager != null) { + try { + final Method getDesktopModeStateMethod = desktopModeManager.getClass() + .getDeclaredMethod("getDesktopModeState"); + final Object desktopModeState = getDesktopModeStateMethod + .invoke(desktopModeManager); + final Class desktopModeStateClass = desktopModeState.getClass(); + final Method getEnabledMethod = desktopModeStateClass + .getDeclaredMethod("getEnabled"); + final int enabledStatus = (int) getEnabledMethod.invoke(desktopModeState); + if (enabledStatus == desktopModeStateClass + .getDeclaredField("ENABLED").getInt(desktopModeStateClass)) { + return true; + } + } catch (final Exception ignored) { + // Device does not support DeX 3.0 or something went wrong when trying to determine + // if it supports this feature + } + } + + return false; + } + + public static boolean isTablet(@NonNull final Context context) { + 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) { + switch (keyCode) { + case KeyEvent.KEYCODE_DPAD_CENTER: + case KeyEvent.KEYCODE_ENTER: + case KeyEvent.KEYCODE_SPACE: + case KeyEvent.KEYCODE_NUMPAD_ENTER: + return true; + default: + return false; + } + } + + public static int dpToPx(@Dimension(unit = Dimension.DP) final int dp, + @NonNull final Context context) { + return (int) TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + dp, + context.getResources().getDisplayMetrics()); + } + + public static int spToPx(@Dimension(unit = Dimension.SP) final int sp, + @NonNull final Context context) { + return (int) TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_SP, + sp, + context.getResources().getDisplayMetrics()); + } + + public static boolean isLandscape(final Context context) { + return context.getResources().getDisplayMetrics().heightPixels < context.getResources() + .getDisplayMetrics().widthPixels; + } + + public static boolean isInMultiWindow(final AppCompatActivity activity) { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && activity.isInMultiWindowMode(); + } + + public static boolean hasAnimationsAnimatorDurationEnabled(final Context context) { + return Settings.System.getFloat( + context.getContentResolver(), + Settings.Global.ANIMATOR_DURATION_SCALE, + 1F) != 0F; + } + + public static int getWindowHeight(@NonNull final WindowManager windowManager) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + final var windowMetrics = windowManager.getCurrentWindowMetrics(); + final var windowInsets = windowMetrics.getWindowInsets(); + final var insets = windowInsets.getInsetsIgnoringVisibility( + WindowInsets.Type.navigationBars() | WindowInsets.Type.displayCutout()); + return windowMetrics.getBounds().height() - (insets.top + insets.bottom); + } else { + final Point point = new Point(); + windowManager.getDefaultDisplay().getSize(point); + return point.y; + } + } + + /** + *

Some devices have broken tunneled video playback but claim to support it.

+ *

This can cause a black video player surface while attempting to play a video or + * crashes while entering or exiting the full screen player. + * The issue effects Android TVs most commonly. + * See #5911 and + * #9023 for more info.

+ * @Note Update {@link #MEDIA_TUNNELING_DEVICE_BLACKLIST_VERSION} + * when adding a new device to the method. + * @return {@code false} if Kitkat (does not support tunneling) or affected device + */ + public static boolean shouldSupportMediaTunneling() { + // Maintainers note: update MEDIA_TUNNELING_DEVICES_UPDATE_APP_VERSION_CODE + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP + && !HI3798MV200 + && !CVT_MT5886_EU_1G + && !REALTEKATV + && !QM16XE_U + && !BRAVIA_VH1 + && !BRAVIA_VH2 + && !BRAVIA_ATV2 + && !BRAVIA_ATV3_4K + && !PH7M_EU_5596 + && !TX_50JXW834 + && !HMB9213NW; + } +} diff --git a/app/src/braveLegacy/java/org/schabi/newpipe/util/PermissionHelper.java b/app/src/braveLegacy/java/org/schabi/newpipe/util/PermissionHelper.java new file mode 100644 index 0000000000..08ae6adbb6 --- /dev/null +++ b/app/src/braveLegacy/java/org/schabi/newpipe/util/PermissionHelper.java @@ -0,0 +1,147 @@ +package org.schabi.newpipe.util; + +import android.Manifest; +import android.app.Activity; +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Build; +import android.provider.Settings; +import android.widget.Toast; + +import androidx.annotation.RequiresApi; +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 POST_NOTIFICATIONS_REQUEST_CODE = 779; + public static final int DOWNLOAD_DIALOG_REQUEST_CODE = 778; + public static final int DOWNLOADS_REQUEST_CODE = 777; + + 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; + } + return checkWriteStoragePermissions(activity, requestCode); + } + + @RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN) + public static boolean checkReadStoragePermissions(final Activity activity, + final int requestCode) { + if (ContextCompat.checkSelfPermission(activity, Manifest.permission.READ_EXTERNAL_STORAGE) + != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions(activity, + new String[]{ + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.WRITE_EXTERNAL_STORAGE}, + requestCode); + + return false; + } + return true; + } + + + public static boolean checkWriteStoragePermissions(final Activity activity, + final int requestCode) { + // Here, thisActivity is the current activity + if (ContextCompat.checkSelfPermission(activity, + Manifest.permission.WRITE_EXTERNAL_STORAGE) + != PackageManager.PERMISSION_GRANTED) { + + // Should we show an explanation? + /*if (ActivityCompat.shouldShowRequestPermissionRationale(activity, + Manifest.permission.WRITE_EXTERNAL_STORAGE)) { + + // Show an explanation to the user *asynchronously* -- don't block + // this thread waiting for the user's response! After the user + // sees the explanation, try again to request the permission. + } else {*/ + + // No explanation needed, we can request the permission. + ActivityCompat.requestPermissions(activity, + new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, requestCode); + + // PERMISSION_WRITE_STORAGE is an + // app-defined int constant. The callback method gets the + // result of the request. + /*}*/ + return false; + } + return true; + } + + public static boolean checkPostNotificationsPermission(final Activity activity, + final int requestCode) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + && ContextCompat.checkSelfPermission(activity, + Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions(activity, + new String[] {Manifest.permission.POST_NOTIFICATIONS}, requestCode); + return false; + } + return true; + } + + /** + * In order to be able to draw over other apps, + * the permission android.permission.SYSTEM_ALERT_WINDOW have to be granted. + *

+ * On < API 23 (MarshMallow) the permission was granted + * when the user installed the application (via AndroidManifest), + * on > 23, however, it have to start a activity asking the user if he agrees. + *

+ *

+ * This method just return if the app has permission to draw over other apps, + * and if it doesn't, it will try to get the permission. + *

+ * + * @param context {@link Context} + * @return {@link Settings#canDrawOverlays(Context)} + **/ + @RequiresApi(api = Build.VERSION_CODES.M) + public static boolean checkSystemAlertWindowPermission(final Context context) { + if (!Settings.canDrawOverlays(context)) { + final Intent i = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, + Uri.parse("package:" + context.getPackageName())); + i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + try { + context.startActivity(i); + } catch (final ActivityNotFoundException ignored) { + } + return false; + } else { + return true; + } + } + + /** + * Determines whether the popup is enabled, and if it is not, starts the system activity to + * request the permission with {@link #checkSystemAlertWindowPermission(Context)} and shows a + * toast to the user explaining why the permission is needed. + * + * @param context the Android context + * @return whether the popup is enabled + */ + public static boolean isPopupEnabledElseAsk(final Context context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M + || checkSystemAlertWindowPermission(context)) { + return true; + } else { + Toast.makeText(context, R.string.msg_popup_permission, Toast.LENGTH_LONG).show(); + return false; + } + } +} diff --git a/app/src/braveLegacy/java/org/schabi/newpipe/util/external_communication/ShareUtils.java b/app/src/braveLegacy/java/org/schabi/newpipe/util/external_communication/ShareUtils.java new file mode 100644 index 0000000000..6ea5a1074e --- /dev/null +++ b/app/src/braveLegacy/java/org/schabi/newpipe/util/external_communication/ShareUtils.java @@ -0,0 +1,420 @@ +package org.schabi.newpipe.util.external_communication; + +import static org.schabi.newpipe.MainActivity.DEBUG; + +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.graphics.Bitmap; +import android.net.Uri; +import android.os.Build; +import android.text.TextUtils; +import android.util.Log; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; +import androidx.core.content.FileProvider; + +import org.schabi.newpipe.BuildConfig; +import org.schabi.newpipe.R; +import org.schabi.newpipe.extractor.Image; +import org.schabi.newpipe.util.image.ImageStrategy; +import org.schabi.newpipe.util.image.PicassoHelper; + +import java.io.File; +import java.io.FileOutputStream; +import java.util.List; + +public final class ShareUtils { + private static final String TAG = ShareUtils.class.getSimpleName(); + + private ShareUtils() { + } + + /** + * Open an Intent to install an app. + *

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

+ * It uses {@link #openIntentInApp(Context, Intent)} to open market scheme and {@link + * #openUrlInBrowser(Context, String)} to open Google Play Store web URL. + * + * @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 Intent marketSchemeIntent = new Intent(Intent.ACTION_VIEW, + Uri.parse("market://details?id=" + packageId)) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + if (!tryOpenIntentInApp(context, marketSchemeIntent)) { + // Fall back to Google Play Store Web URL (F-Droid can handle it) + openUrlInApp(context, "https://play.google.com/store/apps/details?id=" + packageId); + } + } + + /** + * Open the url with the system default browser. If no browser is set as default, falls back to + * {@link #openAppChooser(Context, Intent, boolean)}. + *

+ * This function selects the package to open based on which apps respond to the {@code http://} + * schema alone, which should exclude special non-browser apps that are can handle the url (e.g. + * the official YouTube app). + *

+ * Therefore please prefer {@link #openUrlInApp(Context, String)}, that handles package + * resolution in a standard way, unless this is the action of an explicit "Open in browser" + * button. + * + * @param context the context to use + * @param url the url to browse + **/ + public static void openUrlInBrowser(@NonNull final Context context, final String url) { + // Resolve using a generic http://, so we are sure to get a browser and not e.g. the yt app. + // Note that this requires the `http` schema to be added to `` in the manifest. + final ResolveInfo defaultBrowserInfo; + final Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse("http://")); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + defaultBrowserInfo = context.getPackageManager().resolveActivity(browserIntent, + PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_DEFAULT_ONLY)); + } else { + defaultBrowserInfo = context.getPackageManager().resolveActivity(browserIntent, + PackageManager.MATCH_DEFAULT_ONLY); + } + + final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + + if (defaultBrowserInfo == null) { + // No app installed to open a web URL, but it may be handled by other apps so try + // opening a system chooser for the link in this case (it could be bypassed by the + // system if there is only one app which can open the link or a default app associated + // with the link domain on Android 12 and higher) + openAppChooser(context, intent, true); + return; + } + + final String defaultBrowserPackage = defaultBrowserInfo.activityInfo.packageName; + + if (defaultBrowserPackage.equals("android")) { + // No browser set as default (doesn't work on some devices) + openAppChooser(context, intent, true); + } else { + try { + intent.setPackage(defaultBrowserPackage); + 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); + } + } + } + + /** + * Open a url with the system default app using {@link Intent#ACTION_VIEW}, showing a toast in + * case of failure. + * + * @param context the context to use + * @param url the url to open + */ + public static void openUrlInApp(@NonNull final Context context, final String url) { + openIntentInApp(context, new Intent(Intent.ACTION_VIEW, Uri.parse(url)) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); + } + + /** + * Open an intent with the system default app. + *

+ * Use {@link #openIntentInApp(Context, Intent)} to show a toast in case of failure. + * + * @param context the context to use + * @param intent the intent to open + * @return true if the intent could be opened successfully, false otherwise + */ + public static boolean tryOpenIntentInApp(@NonNull final Context context, + @NonNull final Intent intent) { + try { + context.startActivity(intent); + } catch (final ActivityNotFoundException e) { + return false; + } + return true; + } + + /** + * Open an intent with the system default app, showing a toast in case of failure. + *

+ * Use {@link #tryOpenIntentInApp(Context, Intent)} if you don't want the toast. Use {@link + * #openUrlInApp(Context, String)} as a shorthand for {@link Intent#ACTION_VIEW} with urls. + * + * @param context the context to use + * @param intent the intent to + */ + public static void openIntentInApp(@NonNull final Context context, + @NonNull final Intent intent) { + if (!tryOpenIntentInApp(context, intent)) { + Toast.makeText(context, R.string.no_app_to_open_intent, Toast.LENGTH_LONG) + .show(); + } + } + + /** + * Open the system chooser to launch an intent. + *

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

+ * + * @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 (!TextUtils.isEmpty(title)) { + shareIntent.putExtra(Intent.EXTRA_TITLE, title); + shareIntent.putExtra(Intent.EXTRA_SUBJECT, title); + } + + // Content preview in the share sheet has been added in Android 10, so it's not needed to + // set a content preview which will be never displayed + // See https://developer.android.com/training/sharing/send#adding-rich-content-previews + // If loading of images has been disabled, don't try to generate a content preview + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + && !TextUtils.isEmpty(imagePreviewUrl) + && ImageStrategy.shouldLoadImages()) { + + final ClipData clipData = generateClipDataForImagePreview(context, imagePreviewUrl); + if (clipData != null) { + shareIntent.setClipData(clipData); + 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 and an image preview the content, if the preferred image chosen by {@link + * ImageStrategy#choosePreferredImage(List)} is in the image cache. + *

+ * + * @param context the context to use + * @param title the title of the content + * @param content the content to share + * @param images a set of possible {@link Image}s of the subject, among which to choose with + * {@link ImageStrategy#choosePreferredImage(List)} since that's likely to + * provide an image that is in Picasso's cache + */ + public static void shareText(@NonNull final Context context, + @NonNull final String title, + final String content, + final List images) { + shareText(context, title, content, ImageStrategy.choosePreferredImage(images)); + } + + /** + * Open the android share sheet to share a content. + * + *

+ * This calls {@link #shareText(Context, String, String, String)} with an empty string for the + * {@code imagePreviewUrl} parameter. This method should be used when the shared content has no + * preview thumbnail. + *

+ * + * @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; + } + + try { + clipboardManager.setPrimaryClip(ClipData.newPlainText(null, text)); + if (Build.VERSION.SDK_INT < 33) { + // Android 13 has its own "copied to clipboard" dialog + Toast.makeText(context, R.string.msg_copied, Toast.LENGTH_SHORT).show(); + } + } catch (final Exception e) { + Log.e(TAG, "Error when trying to copy text to clipboard", e); + Toast.makeText(context, R.string.msg_failed_to_copy, Toast.LENGTH_SHORT).show(); + } + } + + /** + * Generate a {@link ClipData} with the image of the content shared, if it's in the app cache. + * + *

+ * In order not to worry about network issues (timeouts, DNS issues, low connection speed, ...) + * when sharing a content, only images in the {@link com.squareup.picasso.LruCache LruCache} + * used by the Picasso library inside {@link PicassoHelper} are used as preview images. If the + * thumbnail image is not in the cache, no {@link ClipData} will be generated and {@code null} + * will be returned. + *

+ * + *

+ * In order to display the image in the content preview of the Android share sheet, an URI of + * the content, accessible and readable by other apps has to be generated, so a new file inside + * the application cache will be generated, named {@code android_share_sheet_image_preview.jpg} + * (if a file under this name already exists, it will be overwritten). The thumbnail will be + * compressed in JPEG format, with a {@code 90} compression level. + *

+ * + *

+ * Note that if an exception occurs when generating the {@link ClipData}, {@code null} is + * returned. + *

+ * + *

+ * This method will call {@link PicassoHelper#getImageFromCacheIfPresent(String)} to get the + * thumbnail of the content in the {@link com.squareup.picasso.LruCache LruCache} used by + * the Picasso library inside {@link PicassoHelper}. + *

+ * + *

+ * Using the result of this method when sharing has only an effect on the system share sheet (if + * OEMs didn't change Android system standard behavior) on Android API 29 and higher. + *

+ * + * @param context the context to use + * @param thumbnailUrl the URL of the content thumbnail + * @return a {@link ClipData} of the content thumbnail, or {@code null} + */ + @Nullable + private static ClipData generateClipDataForImagePreview( + @NonNull final Context context, + @NonNull final String thumbnailUrl) { + try { + final Bitmap bitmap = PicassoHelper.getImageFromCacheIfPresent(thumbnailUrl); + if (bitmap == null) { + return null; + } + + // Save the image in memory to the application's cache because we need a URI to the + // image to generate a ClipData which will show the share sheet, and so an image file + final Context applicationContext = context.getApplicationContext(); + final String appFolder = applicationContext.getCacheDir().getAbsolutePath(); + final File thumbnailPreviewFile = new File(appFolder + + "/android_share_sheet_image_preview.jpg"); + + // Any existing file will be overwritten with FileOutputStream + final FileOutputStream fileOutputStream = new FileOutputStream(thumbnailPreviewFile); + bitmap.compress(Bitmap.CompressFormat.JPEG, 90, fileOutputStream); + fileOutputStream.close(); + + final ClipData clipData = ClipData.newUri(applicationContext.getContentResolver(), "", + FileProvider.getUriForFile(applicationContext, + BuildConfig.APPLICATION_ID + ".provider", + thumbnailPreviewFile)); + + if (DEBUG) { + Log.d(TAG, "ClipData successfully generated for Android share sheet: " + clipData); + } + return clipData; + + } catch (final Exception e) { + Log.w(TAG, "Error when setting preview image for share sheet", e); + return null; + } + } +} diff --git a/app/src/braveLegacy/java/org/schabi/newpipe/views/CollapsibleView.java b/app/src/braveLegacy/java/org/schabi/newpipe/views/CollapsibleView.java new file mode 100644 index 0000000000..e1ada4f9bd --- /dev/null +++ b/app/src/braveLegacy/java/org/schabi/newpipe/views/CollapsibleView.java @@ -0,0 +1,252 @@ +/* + * Copyright 2018 Mauricio Colli + * CollapsibleView.java is part of NewPipe + * + * License: GPL-3.0+ + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.schabi.newpipe.views; + +import android.animation.ValueAnimator; +import android.content.Context; +import android.os.Build; +import android.os.Parcelable; +import android.util.AttributeSet; +import android.util.Log; +import android.widget.LinearLayout; + +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; + +import org.schabi.newpipe.ktx.ViewUtils; + +import java.lang.annotation.Retention; +import java.util.ArrayList; +import java.util.List; + +import icepick.Icepick; +import icepick.State; + +import static java.lang.annotation.RetentionPolicy.SOURCE; +import static org.schabi.newpipe.MainActivity.DEBUG; + +/** + * A view that can be fully collapsed and expanded. + */ +public class CollapsibleView extends LinearLayout { + private static final String TAG = CollapsibleView.class.getSimpleName(); + + private static final int ANIMATION_DURATION = 420; + + public static final int COLLAPSED = 0; + public static final int EXPANDED = 1; + + @State + @ViewMode + int currentState = COLLAPSED; + private boolean readyToChangeState; + + private int targetHeight = -1; + private ValueAnimator currentAnimator; + private final List listeners = new ArrayList<>(); + + public CollapsibleView(final Context context) { + super(context); + } + + public CollapsibleView(final Context context, @Nullable final AttributeSet attrs) { + super(context, attrs); + } + + public CollapsibleView(final Context context, @Nullable final AttributeSet attrs, + final int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + public CollapsibleView(final Context context, final AttributeSet attrs, final int defStyleAttr, + final int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + /*////////////////////////////////////////////////////////////////////////// + // Collapse/expand logic + //////////////////////////////////////////////////////////////////////////*/ + + /** + * This method recalculates the height of this view so it must be called when + * some child changes (e.g. add new views, change text). + */ + public void ready() { + if (DEBUG) { + Log.d(TAG, getDebugLogString("ready() called")); + } + + measure(MeasureSpec.makeMeasureSpec(getWidth(), MeasureSpec.AT_MOST), + MeasureSpec.UNSPECIFIED); + targetHeight = getMeasuredHeight(); + + getLayoutParams().height = currentState == COLLAPSED ? 0 : targetHeight; + requestLayout(); + broadcastState(); + + readyToChangeState = true; + + if (DEBUG) { + Log.d(TAG, getDebugLogString("ready() *after* measuring")); + } + } + + public void collapse() { + if (DEBUG) { + Log.d(TAG, getDebugLogString("collapse() called")); + } + + if (!readyToChangeState) { + return; + } + + final int height = getHeight(); + if (height == 0) { + setCurrentState(COLLAPSED); + return; + } + + if (currentAnimator != null && currentAnimator.isRunning()) { + currentAnimator.cancel(); + } + currentAnimator = ViewUtils.animateHeight(this, ANIMATION_DURATION, 0); + + setCurrentState(COLLAPSED); + } + + public void expand() { + if (DEBUG) { + Log.d(TAG, getDebugLogString("expand() called")); + } + + if (!readyToChangeState) { + return; + } + + final int height = getHeight(); + if (height == this.targetHeight) { + setCurrentState(EXPANDED); + return; + } + + if (currentAnimator != null && currentAnimator.isRunning()) { + currentAnimator.cancel(); + } + currentAnimator = ViewUtils.animateHeight(this, ANIMATION_DURATION, this.targetHeight); + setCurrentState(EXPANDED); + } + + public void switchState() { + if (!readyToChangeState) { + return; + } + + if (currentState == COLLAPSED) { + expand(); + } else { + collapse(); + } + } + + @ViewMode + public int getCurrentState() { + return currentState; + } + + public void setCurrentState(@ViewMode final int currentState) { + this.currentState = currentState; + broadcastState(); + } + + public void broadcastState() { + for (final StateListener listener : listeners) { + listener.onStateChanged(currentState); + } + } + + /** + * Add a listener which will be listening for changes in this view (i.e. collapsed or expanded). + * @param listener {@link StateListener} to be added + */ + public void addListener(final StateListener listener) { + if (listeners.contains(listener)) { + throw new IllegalStateException("Trying to add the same listener multiple times"); + } + + listeners.add(listener); + } + + /** + * Remove a listener so it doesn't receive more state changes. + * @param listener {@link StateListener} to be removed + */ + public void removeListener(final StateListener listener) { + listeners.remove(listener); + } + + /*////////////////////////////////////////////////////////////////////////// + // State Saving + //////////////////////////////////////////////////////////////////////////*/ + + @Nullable + @Override + public Parcelable onSaveInstanceState() { + return Icepick.saveInstanceState(this, super.onSaveInstanceState()); + } + + @Override + public void onRestoreInstanceState(final Parcelable state) { + super.onRestoreInstanceState(Icepick.restoreInstanceState(this, state)); + + ready(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Internal + //////////////////////////////////////////////////////////////////////////*/ + + public String getDebugLogString(final String description) { + return String.format("%-100s → %s", + description, "readyToChangeState = [" + readyToChangeState + "], " + + "currentState = [" + currentState + "], " + + "targetHeight = [" + targetHeight + "], " + + "mW x mH = [" + getMeasuredWidth() + "x" + getMeasuredHeight() + "], " + + "W x H = [" + getWidth() + "x" + getHeight() + "]"); + } + + @Retention(SOURCE) + @IntDef({COLLAPSED, EXPANDED}) + public @interface ViewMode { } + + /** + * Simple interface used for listening state changes of the {@link CollapsibleView}. + */ + public interface StateListener { + /** + * Called when the state changes. + * + * @param newState the state that the {@link CollapsibleView} transitioned to,
+ * it's an integer being either {@link #COLLAPSED} or {@link #EXPANDED} + */ + void onStateChanged(@ViewMode int newState); + } +} diff --git a/app/src/braveLegacy/java/org/schabi/newpipe/views/ExpandableSurfaceView.java b/app/src/braveLegacy/java/org/schabi/newpipe/views/ExpandableSurfaceView.java new file mode 100644 index 0000000000..cfa17e20c0 --- /dev/null +++ b/app/src/braveLegacy/java/org/schabi/newpipe/views/ExpandableSurfaceView.java @@ -0,0 +1,114 @@ +package org.schabi.newpipe.views; + +import android.content.Context; +import android.os.Build; +import android.util.AttributeSet; +import android.view.SurfaceView; + +import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; + +import static com.google.android.exoplayer2.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT; +import static com.google.android.exoplayer2.ui.AspectRatioFrameLayout.RESIZE_MODE_ZOOM; + +public class ExpandableSurfaceView extends SurfaceView { + private int resizeMode = RESIZE_MODE_FIT; + private int baseHeight = 0; + private int maxHeight = 0; + private float videoAspectRatio = 0.0f; + private float scaleX = 1.0f; + private float scaleY = 1.0f; + + public ExpandableSurfaceView(final Context context, final AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + if (videoAspectRatio == 0.0f) { + return; + } + + int width = MeasureSpec.getSize(widthMeasureSpec); + final boolean verticalVideo = videoAspectRatio < 1; + // Use maxHeight only on non-fit resize mode and in vertical videos + int height = maxHeight != 0 + && resizeMode != RESIZE_MODE_FIT + && verticalVideo ? maxHeight : baseHeight; + + if (height == 0) { + return; + } + + final float viewAspectRatio = width / ((float) height); + final float aspectDeformation = videoAspectRatio / viewAspectRatio - 1; + scaleX = 1.0f; + scaleY = 1.0f; + + if (resizeMode == RESIZE_MODE_FIT + // KitKat doesn't work well when a view has a scale like needed for ZOOM + || (resizeMode == RESIZE_MODE_ZOOM + && Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP)) { + if (aspectDeformation > 0) { + height = (int) (width / videoAspectRatio); + } else { + width = (int) (height * videoAspectRatio); + } + } else if (resizeMode == RESIZE_MODE_ZOOM) { + if (aspectDeformation < 0) { + scaleY = viewAspectRatio / videoAspectRatio; + } else { + scaleX = videoAspectRatio / viewAspectRatio; + } + } + + super.onMeasure(MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)); + } + + /** + * Scale view only in {@link #onLayout} to make transition for ZOOM mode as smooth as possible. + */ + @Override + protected void onLayout(final boolean changed, + final int left, final int top, final int right, final int bottom) { + setScaleX(scaleX); + setScaleY(scaleY); + } + + /** + * @param base The height that will be used in every resize mode as a minimum height + * @param max The max height for vertical videos in non-FIT resize modes + */ + public void setHeights(final int base, final int max) { + if (baseHeight == base && maxHeight == max) { + return; + } + baseHeight = base; + maxHeight = max; + requestLayout(); + } + + public void setResizeMode(@AspectRatioFrameLayout.ResizeMode final int newResizeMode) { + if (resizeMode == newResizeMode) { + return; + } + + resizeMode = newResizeMode; + requestLayout(); + } + + @AspectRatioFrameLayout.ResizeMode + public int getResizeMode() { + return resizeMode; + } + + public void setAspectRatio(final float aspectRatio) { + if (videoAspectRatio == aspectRatio) { + return; + } + + videoAspectRatio = aspectRatio; + requestLayout(); + } +} diff --git a/app/src/braveLegacy/java/org/schabi/newpipe/views/FocusAwareCoordinator.java b/app/src/braveLegacy/java/org/schabi/newpipe/views/FocusAwareCoordinator.java new file mode 100644 index 0000000000..e4acb00b70 --- /dev/null +++ b/app/src/braveLegacy/java/org/schabi/newpipe/views/FocusAwareCoordinator.java @@ -0,0 +1,108 @@ +/* + * Copyright (C) Eltex ltd 2019 + * FocusAwareCoordinator.java is part of NewPipe. + * + * NewPipe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * NewPipe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with NewPipe. If not, see . + */ +package org.schabi.newpipe.views; + +import android.annotation.TargetApi; +import android.content.Context; +import android.graphics.Rect; +import android.os.Build; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowInsets; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.coordinatorlayout.widget.CoordinatorLayout; +import androidx.core.view.WindowInsetsCompat; + +import org.schabi.newpipe.R; + +public final class FocusAwareCoordinator extends CoordinatorLayout { + private final Rect childFocus = new Rect(); + + public FocusAwareCoordinator(@NonNull final Context context) { + super(context); + } + + public FocusAwareCoordinator(@NonNull final Context context, + @Nullable final AttributeSet attrs) { + super(context, attrs); + } + + public FocusAwareCoordinator(@NonNull final Context context, + @Nullable final AttributeSet attrs, final int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + public void requestChildFocus(final View child, final View focused) { + super.requestChildFocus(child, focused); + + if (!isInTouchMode()) { + if (focused.getHeight() >= getHeight()) { + focused.getFocusedRect(childFocus); + + ((ViewGroup) child).offsetDescendantRectToMyCoords(focused, childFocus); + } else { + focused.getHitRect(childFocus); + + ((ViewGroup) child).offsetDescendantRectToMyCoords((View) focused.getParent(), + childFocus); + } + + requestChildRectangleOnScreen(child, childFocus, false); + } + } + + /** + * Applies window insets to all children, not just for the first who consume the insets. + * Makes possible for multiple fragments to co-exist. Without this code + * the first ViewGroup who consumes will be the last who receive the insets + */ + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + @Override + public WindowInsets dispatchApplyWindowInsets(final WindowInsets insets) { + boolean consumed = false; + for (int i = 0; i < getChildCount(); i++) { + final View child = getChildAt(i); + final WindowInsets res = child.dispatchApplyWindowInsets(insets); + if (res.isConsumed()) { + consumed = true; + } + } + + return consumed ? WindowInsetsCompat.CONSUMED.toWindowInsets() : insets; + } + + /** + * Adjusts player's controls manually because onApplyWindowInsets doesn't work when multiple + * receivers adjust its bounds. So when two listeners are present (like in profile page) + * the player's controls will not receive insets. This method fixes it + */ + @Override + public WindowInsets onApplyWindowInsets(final WindowInsets windowInsets) { + final var windowInsetsCompat = WindowInsetsCompat.toWindowInsetsCompat(windowInsets, this); + final var insets = windowInsetsCompat.getInsets(WindowInsetsCompat.Type.systemBars()); + final ViewGroup controls = findViewById(R.id.playbackControlRoot); + if (controls != null) { + controls.setPadding(insets.left, insets.top, insets.right, insets.bottom); + } + return super.onApplyWindowInsets(windowInsets); + } +} diff --git a/app/src/braveLegacy/java/us/shandian/giga/get/DownloadMission.java b/app/src/braveLegacy/java/us/shandian/giga/get/DownloadMission.java new file mode 100644 index 0000000000..f5b76887a8 --- /dev/null +++ b/app/src/braveLegacy/java/us/shandian/giga/get/DownloadMission.java @@ -0,0 +1,857 @@ +package us.shandian.giga.get; + +import android.os.Build; +import android.os.Handler; +import android.system.ErrnoException; +import android.system.OsConstants; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.schabi.newpipe.DownloaderImpl; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InterruptedIOException; +import java.io.Serializable; +import java.net.ConnectException; +import java.net.HttpURLConnection; +import java.net.SocketTimeoutException; +import java.net.URL; +import java.net.UnknownHostException; +import java.nio.channels.ClosedByInterruptException; +import java.util.Objects; + +import javax.net.ssl.SSLException; + +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; + +import static org.schabi.newpipe.BuildConfig.DEBUG; + +public class DownloadMission extends Mission { + private static final long serialVersionUID = 6L;// last bump: 07 october 2019 + + static final int BUFFER_SIZE = 64 * 1024; + static final int BLOCK_SIZE = 512 * 1024; + + private static final String TAG = "DownloadMission"; + + public static final int ERROR_NOTHING = -1; + public static final int ERROR_PATH_CREATION = 1000; + public static final int ERROR_FILE_CREATION = 1001; + public static final int ERROR_UNKNOWN_EXCEPTION = 1002; + public static final int ERROR_PERMISSION_DENIED = 1003; + public static final int ERROR_SSL_EXCEPTION = 1004; + public static final int ERROR_UNKNOWN_HOST = 1005; + public static final int ERROR_CONNECT_HOST = 1006; + public static final int ERROR_POSTPROCESSING = 1007; + public static final int ERROR_POSTPROCESSING_STOPPED = 1008; + public static final int ERROR_POSTPROCESSING_HOLD = 1009; + public static final int ERROR_INSUFFICIENT_STORAGE = 1010; + public static final int ERROR_PROGRESS_LOST = 1011; + public static final int ERROR_TIMEOUT = 1012; + public static final int ERROR_RESOURCE_GONE = 1013; + public static final int ERROR_HTTP_NO_CONTENT = 204; + static final int ERROR_HTTP_FORBIDDEN = 403; + static final int ERROR_HTTP_METHOD_NOT_ALLOWED = 405; + + /** + * The urls of the file to download + */ + public String[] urls; + + /** + * Number of bytes downloaded and written + */ + public volatile long done; + + /** + * Indicates a file generated dynamically on the web server + */ + public boolean unknownLength; + + /** + * offset in the file where the data should be written + */ + public long[] offsets; + + /** + * Indicates if the post-processing state: + * 0: ready + * 1: running + * 2: completed + * 3: hold + */ + public volatile int psState; + + /** + * the post-processing algorithm instance + */ + public Postprocessing psAlgorithm; + + /** + * The current resource to download, {@code urls[current]} and {@code offsets[current]} + */ + public int current; + + /** + * Metadata where the mission state is saved + */ + public transient File metadata; + + /** + * maximum attempts + */ + public transient int maxRetry; + + /** + * Approximated final length, this represent the sum of all resources sizes + */ + public long nearLength; + + /** + * Download blocks, the size is multiple of {@link DownloadMission#BLOCK_SIZE}. + * Every entry (block) in this array holds an offset, used to resume the download. + * An block offset can be -1 if the block was downloaded successfully. + */ + int[] blocks; + + /** + * Download/File resume offset in fallback mode (if applicable) {@link DownloadRunnableFallback} + */ + volatile long fallbackResumeOffset; + + /** + * Maximum of download threads running, chosen by the user + */ + public int threadCount = 3; + + /** + * information required to recover a download + */ + public MissionRecoveryInfo[] recoveryInfo; + + private transient int finishCount; + public transient volatile boolean running; + public boolean enqueued; + + public int errCode = ERROR_NOTHING; + public Exception errObject = null; + + public transient Handler mHandler; + private transient boolean[] blockAcquired; + + private transient long writingToFileNext; + private transient volatile boolean writingToFile; + + final Object LOCK = new Lock(); + + @NonNull + public transient Thread[] threads = new Thread[0]; + public transient Thread init = null; + + public DownloadMission(String[] urls, StoredFileHelper storage, char kind, Postprocessing psInstance) { + if (Objects.requireNonNull(urls).length < 1) + throw new IllegalArgumentException("urls array is empty"); + this.urls = urls; + this.kind = kind; + this.offsets = new long[urls.length]; + this.enqueued = true; + this.maxRetry = 3; + this.storage = storage; + this.psAlgorithm = psInstance; + + if (DEBUG && psInstance == null && urls.length > 1) { + Log.w(TAG, "mission created with multiple urls ¿missing post-processing algorithm?"); + } + } + + /** + * Acquire a block + * + * @return the block or {@code null} if no more blocks left + */ + @Nullable + Block acquireBlock() { + synchronized (LOCK) { + for (int i = 0; i < blockAcquired.length; i++) { + if (!blockAcquired[i] && blocks[i] >= 0) { + Block block = new Block(); + block.position = i; + block.done = blocks[i]; + + blockAcquired[i] = true; + return block; + } + } + } + + return null; + } + + /** + * Release an block + * + * @param position the index of the block + * @param done amount of bytes downloaded + */ + void releaseBlock(int position, int done) { + synchronized (LOCK) { + blockAcquired[position] = false; + blocks[position] = done; + } + } + + /** + * Opens a connection + * + * @param headRequest {@code true} for use {@code HEAD} request method, otherwise, {@code GET} is used + * @param rangeStart range start + * @param rangeEnd range end + * @return a {@link java.net.URLConnection URLConnection} linking to the URL. + * @throws IOException if an I/O exception occurs. + */ + HttpURLConnection openConnection(boolean headRequest, long rangeStart, long rangeEnd) throws IOException { + return openConnection(urls[current], headRequest, rangeStart, rangeEnd); + } + + HttpURLConnection openConnection(String url, boolean headRequest, long rangeStart, long rangeEnd) throws IOException { + HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); + conn.setInstanceFollowRedirects(true); + conn.setRequestProperty("User-Agent", DownloaderImpl.USER_AGENT); + conn.setRequestProperty("Accept", "*/*"); + conn.setRequestProperty("Accept-Encoding", "*"); + + if (headRequest) conn.setRequestMethod("HEAD"); + + // BUG workaround: switching between networks can freeze the download forever + conn.setConnectTimeout(30000); + + if (rangeStart >= 0) { + String req = "bytes=" + rangeStart + "-"; + if (rangeEnd > 0) req += rangeEnd; + + conn.setRequestProperty("Range", req); + } + + return conn; + } + + /** + * @param threadId id of the calling thread + * @param conn Opens and establish the communication + * @throws IOException if an error occurred connecting to the server. + * @throws HttpError if the HTTP Status-Code is not satisfiable + */ + void establishConnection(int threadId, HttpURLConnection conn) throws IOException, HttpError { + int statusCode = conn.getResponseCode(); + + if (DEBUG) { + Log.d(TAG, threadId + ":[request] Range=" + conn.getRequestProperty("Range")); + Log.d(TAG, threadId + ":[response] Code=" + statusCode); + Log.d(TAG, threadId + ":[response] Content-Length=" + conn.getContentLength()); + Log.d(TAG, threadId + ":[response] Content-Range=" + conn.getHeaderField("Content-Range")); + } + + + switch (statusCode) { + case 204: + case 205: + case 207: + throw new HttpError(statusCode); + case 416: + return;// let the download thread handle this error + default: + if (statusCode < 200 || statusCode > 299) { + throw new HttpError(statusCode); + } + } + + } + + + private void notify(int what) { + mHandler.obtainMessage(what, this).sendToTarget(); + } + + synchronized void notifyProgress(long deltaLen) { + if (unknownLength) { + length += deltaLen;// Update length before proceeding + } + + done += deltaLen; + + if (metadata == null) return; + + if (!writingToFile && (done > writingToFileNext || deltaLen < 0)) { + writingToFile = true; + writingToFileNext = done + BLOCK_SIZE; + writeThisToFileAsync(); + } + } + + synchronized void notifyError(Exception err) { + Log.e(TAG, "notifyError()", err); + + if (err instanceof FileNotFoundException) { + notifyError(ERROR_FILE_CREATION, null); + } else if (err instanceof SSLException) { + notifyError(ERROR_SSL_EXCEPTION, null); + } else if (err instanceof HttpError) { + notifyError(((HttpError) err).statusCode, null); + } else if (err instanceof ConnectException) { + notifyError(ERROR_CONNECT_HOST, null); + } else if (err instanceof UnknownHostException) { + notifyError(ERROR_UNKNOWN_HOST, null); + } else if (err instanceof SocketTimeoutException) { + notifyError(ERROR_TIMEOUT, null); + } else { + notifyError(ERROR_UNKNOWN_EXCEPTION, err); + } + } + + public synchronized void notifyError(int code, Exception err) { + Log.e(TAG, "notifyError() code = " + code, err); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + if (err != null && err.getCause() instanceof ErrnoException) { + int errno = ((ErrnoException) err.getCause()).errno; + if (errno == OsConstants.ENOSPC) { + code = ERROR_INSUFFICIENT_STORAGE; + err = null; + } else if (errno == OsConstants.EACCES) { + code = ERROR_PERMISSION_DENIED; + err = null; + } + } + } + + if (err instanceof IOException) { + if (err.getMessage().contains("Permission denied")) { + code = ERROR_PERMISSION_DENIED; + err = null; + } else if (err.getMessage().contains("ENOSPC")) { + code = ERROR_INSUFFICIENT_STORAGE; + err = null; + } else if (!storage.canWrite()) { + code = ERROR_FILE_CREATION; + err = null; + } + } + + errCode = code; + errObject = err; + + switch (code) { + case ERROR_SSL_EXCEPTION: + case ERROR_UNKNOWN_HOST: + case ERROR_CONNECT_HOST: + case ERROR_TIMEOUT: + // do not change the queue flag for network errors, can be + // recovered silently without the user interaction + break; + default: + // also checks for server errors + if (code < 500 || code > 599) enqueued = false; + } + + notify(DownloadManagerService.MESSAGE_ERROR); + + if (running) pauseThreads(); + } + + synchronized void notifyFinished() { + if (current < urls.length) { + if (++finishCount < threads.length) return; + + if (DEBUG) { + Log.d(TAG, "onFinish: downloaded " + (current + 1) + "/" + urls.length); + } + + current++; + if (current < urls.length) { + // prepare next sub-mission + offsets[current] = offsets[current - 1] + length; + initializer(); + return; + } + } + + if (psAlgorithm != null && psState == 0) { + threads = new Thread[]{ + runAsync(1, this::doPostprocessing) + }; + return; + } + + + // this mission is fully finished + + unknownLength = false; + enqueued = false; + running = false; + + deleteThisFromFile(); + notify(DownloadManagerService.MESSAGE_FINISHED); + } + + private void notifyPostProcessing(int state) { + String action; + switch (state) { + case 1: + action = "Running"; + break; + case 2: + action = "Completed"; + break; + default: + action = "Failed"; + } + + Log.d(TAG, action + " postprocessing on " + storage.getName()); + + if (state == 2) { + psState = state; + return; + } + + synchronized (LOCK) { + // don't return without fully write the current state + psState = state; + writeThisToFile(); + } + } + + + /** + * Start downloading with multiple threads. + */ + public void start() { + if (running || isFinished() || urls.length < 1) return; + + // ensure that the previous state is completely paused. + joinForThreads(10000); + + running = true; + errCode = ERROR_NOTHING; + + if (hasInvalidStorage()) { + notifyError(ERROR_FILE_CREATION, null); + return; + } + + if (current >= urls.length) { + notifyFinished(); + return; + } + + notify(DownloadManagerService.MESSAGE_RUNNING); + + if (urls[current] == null) { + doRecover(ERROR_RESOURCE_GONE); + return; + } + + if (blocks == null) { + initializer(); + return; + } + + init = null; + finishCount = 0; + blockAcquired = new boolean[blocks.length]; + + if (blocks.length < 1) { + threads = new Thread[]{runAsync(1, new DownloadRunnableFallback(this))}; + } else { + int remainingBlocks = 0; + for (int block : blocks) if (block >= 0) remainingBlocks++; + + if (remainingBlocks < 1) { + notifyFinished(); + return; + } + + threads = new Thread[Math.min(threadCount, remainingBlocks)]; + + for (int i = 0; i < threads.length; i++) { + threads[i] = runAsync(i + 1, new DownloadRunnable(this, i)); + } + } + } + + /** + * Pause the mission + */ + public void pause() { + if (!running) return; + + if (isPsRunning()) { + if (DEBUG) { + Log.w(TAG, "pause during post-processing is not applicable."); + } + return; + } + + running = false; + notify(DownloadManagerService.MESSAGE_PAUSED); + + if (init != null && init.isAlive()) { + // NOTE: if start() method is running ¡will no have effect! + init.interrupt(); + synchronized (LOCK) { + resetState(false, true, ERROR_NOTHING); + } + return; + } + + if (DEBUG && unknownLength) { + Log.w(TAG, "pausing a download that can not be resumed (range requests not allowed by the server)."); + } + + init = null; + pauseThreads(); + } + + private void pauseThreads() { + running = false; + joinForThreads(-1); + writeThisToFile(); + } + + /** + * Removes the downloaded file and the meta file + */ + @Override + public boolean delete() { + if (psAlgorithm != null) psAlgorithm.cleanupTemporalDir(); + + notify(DownloadManagerService.MESSAGE_DELETED); + + boolean res = deleteThisFromFile(); + + if (!super.delete()) return false; + return res; + } + + + /** + * Resets the mission state + * + * @param rollback {@code true} true to forget all progress, otherwise, {@code false} + * @param persistChanges {@code true} to commit changes to the metadata file, otherwise, {@code false} + */ + public void resetState(boolean rollback, boolean persistChanges, int errorCode) { + length = 0; + errCode = errorCode; + errObject = null; + unknownLength = false; + threads = new Thread[0]; + fallbackResumeOffset = 0; + blocks = null; + blockAcquired = null; + + if (rollback) current = 0; + if (persistChanges) writeThisToFile(); + } + + private void initializer() { + init = runAsync(DownloadInitializer.mId, new DownloadInitializer(this)); + } + + private void writeThisToFileAsync() { + runAsync(-2, this::writeThisToFile); + } + + /** + * Write this {@link DownloadMission} to the meta file asynchronously + * if no thread is already running. + */ + void writeThisToFile() { + synchronized (LOCK) { + if (metadata == null) return; + Utility.writeToFile(metadata, this); + writingToFile = false; + } + } + + /** + * Indicates if the download if fully finished + * + * @return true, otherwise, false + */ + public boolean isFinished() { + return current >= urls.length && (psAlgorithm == null || psState == 2); + } + + /** + * Indicates if the download file is corrupt due a failed post-processing + * + * @return {@code true} if this mission is unrecoverable + */ + public boolean isPsFailed() { + switch (errCode) { + case ERROR_POSTPROCESSING: + case ERROR_POSTPROCESSING_STOPPED: + return psAlgorithm.worksOnSameFile; + } + + return false; + } + + /** + * Indicates if a post-processing algorithm is running + * + * @return true, otherwise, false + */ + public boolean isPsRunning() { + return psAlgorithm != null && (psState == 1 || psState == 3); + } + + /** + * Indicated if the mission is ready + * + * @return true, otherwise, false + */ + public boolean isInitialized() { + return blocks != null; // DownloadMissionInitializer was executed + } + + /** + * Gets the approximated final length of the file + * + * @return the length in bytes + */ + public long getLength() { + long calculated; + if (psState == 1 || psState == 3) { + return length; + } + + calculated = offsets[current < offsets.length ? current : (offsets.length - 1)] + length; + calculated -= offsets[0];// don't count reserved space + + return Math.max(calculated, nearLength); + } + + /** + * set this mission state on the queue + * + * @param queue true to add to the queue, otherwise, false + */ + public void setEnqueued(boolean queue) { + enqueued = queue; + writeThisToFileAsync(); + } + + /** + * Attempts to continue a blocked post-processing + * + * @param recover {@code true} to retry, otherwise, {@code false} to cancel + */ + public void psContinue(boolean recover) { + psState = 1; + errCode = recover ? ERROR_NOTHING : ERROR_POSTPROCESSING; + threads[0].interrupt(); + } + + /** + * Indicates whatever the backed storage is invalid + * + * @return {@code true}, if storage is invalid and cannot be used + */ + public boolean hasInvalidStorage() { + return errCode == ERROR_PROGRESS_LOST || storage == null || !storage.existsAsFile(); + } + + /** + * Indicates whatever is possible to start the mission + * + * @return {@code true} is this mission its "healthy", otherwise, {@code false} + */ + public boolean isCorrupt() { + if (urls.length < 1) return false; + return (isPsFailed() || errCode == ERROR_POSTPROCESSING_HOLD) || isFinished(); + } + + /** + * Indicates if mission urls has expired and there an attempt to renovate them + * + * @return {@code true} if the mission is running a recovery procedure, otherwise, {@code false} + */ + public boolean isRecovering() { + return threads.length > 0 && threads[0] instanceof DownloadMissionRecover && threads[0].isAlive(); + } + + private void doPostprocessing() { + errCode = ERROR_NOTHING; + errObject = null; + Thread thread = Thread.currentThread(); + + notifyPostProcessing(1); + + if (DEBUG) { + thread.setName("[" + TAG + "] ps = " + psAlgorithm + " filename = " + storage.getName()); + } + + Exception exception = null; + + try { + psAlgorithm.run(this); + } catch (Exception err) { + Log.e(TAG, "Post-processing failed. " + psAlgorithm.toString(), err); + + if (err instanceof InterruptedIOException || err instanceof ClosedByInterruptException || thread.isInterrupted()) { + notifyError(DownloadMission.ERROR_POSTPROCESSING_STOPPED, null); + return; + } + + if (errCode == ERROR_NOTHING) errCode = ERROR_POSTPROCESSING; + + exception = err; + } finally { + notifyPostProcessing(errCode == ERROR_NOTHING ? 2 : 0); + } + + if (errCode != ERROR_NOTHING) { + if (exception == null) exception = errObject; + notifyError(ERROR_POSTPROCESSING, exception); + return; + } + + notifyFinished(); + } + + /** + * Attempts to recover the download + * + * @param errorCode error code which trigger the recovery procedure + */ + void doRecover(int errorCode) { + Log.i(TAG, "Attempting to recover the mission: " + storage.getName()); + + if (recoveryInfo == null) { + notifyError(errorCode, null); + urls = new String[0];// mark this mission as dead + return; + } + + joinForThreads(0); + + threads = new Thread[]{ + runAsync(DownloadMissionRecover.mID, new DownloadMissionRecover(this, errorCode)) + }; + } + + private boolean deleteThisFromFile() { + synchronized (LOCK) { + boolean res = metadata.delete(); + metadata = null; + return res; + } + } + + /** + * run a new thread + * + * @param id id of new thread (used for debugging only) + * @param who the Runnable whose {@code run} method is invoked. + */ + private Thread runAsync(int id, Runnable who) { + return runAsync(id, new Thread(who)); + } + + /** + * run a new thread + * + * @param id id of new thread (used for debugging only) + * @param who the Thread whose {@code run} method is invoked when this thread is started + * @return the passed thread + */ + private Thread runAsync(int id, Thread who) { + // known thread ids: + // -2: state saving by notifyProgress() method + // -1: wait for saving the state by pause() method + // 0: initializer + // >=1: any download thread + + if (DEBUG) { + who.setName(String.format("%s[%s] %s", TAG, id, storage.getName())); + } + + who.start(); + + return who; + } + + /** + * Waits at most {@code millis} milliseconds for the thread to die + * + * @param millis the time to wait in milliseconds + */ + private void joinForThreads(int millis) { + final Thread currentThread = Thread.currentThread(); + + if (init != null && init != currentThread && init.isAlive()) { + init.interrupt(); + + if (millis > 0) { + try { + init.join(millis); + } catch (InterruptedException e) { + Log.w(TAG, "Initializer thread is still running", e); + return; + } + } + } + + // if a thread is still alive, possible reasons: + // slow device + // the user is spamming start/pause buttons + // start() method called quickly after pause() + + for (Thread thread : threads) { + if (!thread.isAlive() || thread == Thread.currentThread()) continue; + thread.interrupt(); + } + + try { + for (Thread thread : threads) { + if (!thread.isAlive()) continue; + if (DEBUG) { + Log.w(TAG, "thread alive: " + thread.getName()); + } + if (millis > 0) thread.join(millis); + } + } catch (InterruptedException e) { + throw new RuntimeException("A download thread is still running", e); + } + } + + + static class HttpError extends Exception { + final int statusCode; + + HttpError(int statusCode) { + this.statusCode = statusCode; + } + + @Override + public String getMessage() { + return "HTTP " + statusCode; + } + } + + public static class Block { + public int position; + public int done; + } + + private static class Lock implements Serializable { + // java.lang.Object cannot be used because is not serializable + } +} diff --git a/app/src/braveLegacy/java/us/shandian/giga/service/DownloadManagerService.java b/app/src/braveLegacy/java/us/shandian/giga/service/DownloadManagerService.java new file mode 100755 index 0000000000..601f7984a0 --- /dev/null +++ b/app/src/braveLegacy/java/us/shandian/giga/service/DownloadManagerService.java @@ -0,0 +1,668 @@ +package us.shandian.giga.service; + +import static org.schabi.newpipe.BuildConfig.APPLICATION_ID; +import static org.schabi.newpipe.BuildConfig.DEBUG; + +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.content.SharedPreferences.OnSharedPreferenceChangeListener; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.net.ConnectivityManager; +import android.net.Network; +import android.net.NetworkInfo; +import android.net.NetworkRequest; +import android.net.Uri; +import android.os.Binder; +import android.os.Build; +import android.os.Handler; +import android.os.Handler.Callback; +import android.os.IBinder; +import android.os.Message; +import android.util.Log; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.collection.SparseArrayCompat; +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationCompat.Builder; +import androidx.core.app.PendingIntentCompat; +import androidx.core.app.ServiceCompat; +import androidx.core.content.ContextCompat; +import androidx.core.content.IntentCompat; +import androidx.preference.PreferenceManager; + +import com.grack.nanojson.JsonStringWriter; +import com.grack.nanojson.JsonWriter; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.download.DownloadActivity; +import org.schabi.newpipe.player.helper.LockManager; +import org.schabi.newpipe.streams.io.StoredDirectoryHelper; +import org.schabi.newpipe.streams.io.StoredFileHelper; +import org.schabi.newpipe.util.Localization; +import org.schabi.newpipe.util.VideoSegment; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import us.shandian.giga.get.DownloadMission; +import us.shandian.giga.get.MissionRecoveryInfo; +import us.shandian.giga.postprocessing.Postprocessing; +import us.shandian.giga.service.DownloadManager.NetworkState; + +public class DownloadManagerService extends Service { + + private static final String TAG = "DownloadManagerService"; + + public static final int MESSAGE_RUNNING = 0; + public static final int MESSAGE_PAUSED = 1; + public static final int MESSAGE_FINISHED = 2; + public static final int MESSAGE_ERROR = 3; + public static final int MESSAGE_DELETED = 4; + + private static final int FOREGROUND_NOTIFICATION_ID = 1000; + private static final int DOWNLOADS_NOTIFICATION_ID = 1001; + + private static final String EXTRA_URLS = "DownloadManagerService.extra.urls"; + private static final String EXTRA_KIND = "DownloadManagerService.extra.kind"; + private static final String EXTRA_THREADS = "DownloadManagerService.extra.threads"; + private static final String EXTRA_POSTPROCESSING_NAME = "DownloadManagerService.extra.postprocessingName"; + private static final String EXTRA_POSTPROCESSING_ARGS = "DownloadManagerService.extra.postprocessingArgs"; + private static final String EXTRA_SOURCE = "DownloadManagerService.extra.source"; + private static final String EXTRA_NEAR_LENGTH = "DownloadManagerService.extra.nearLength"; + private static final String EXTRA_PATH = "DownloadManagerService.extra.storagePath"; + private static final String EXTRA_PARENT_PATH = "DownloadManagerService.extra.storageParentPath"; + private static final String EXTRA_STORAGE_TAG = "DownloadManagerService.extra.storageTag"; + private static final String EXTRA_RECOVERY_INFO = "DownloadManagerService.extra.recoveryInfo"; + private static final String EXTRA_SEGMENTS = "DownloadManagerService.extra.segments"; + + private static final String ACTION_RESET_DOWNLOAD_FINISHED = APPLICATION_ID + ".reset_download_finished"; + private static final String ACTION_OPEN_DOWNLOADS_FINISHED = APPLICATION_ID + ".open_downloads_finished"; + + private DownloadManagerBinder mBinder; + private DownloadManager mManager; + private Notification mNotification; + private Handler mHandler; + private boolean mForeground = false; + private NotificationManager mNotificationManager = null; + private boolean mDownloadNotificationEnable = true; + + private int downloadDoneCount = 0; + private Builder downloadDoneNotification = null; + private StringBuilder downloadDoneList = null; + + private final List mEchoObservers = new ArrayList<>(1); + + private ConnectivityManager mConnectivityManager; + private BroadcastReceiver mNetworkStateListener = null; + private ConnectivityManager.NetworkCallback mNetworkStateListenerL = null; + + private SharedPreferences mPrefs = null; + private final OnSharedPreferenceChangeListener mPrefChangeListener = this::handlePreferenceChange; + + private boolean mLockAcquired = false; + private LockManager mLock = null; + + private int downloadFailedNotificationID = DOWNLOADS_NOTIFICATION_ID + 1; + private Builder downloadFailedNotification = null; + private final SparseArrayCompat mFailedDownloads = + new SparseArrayCompat<>(5); + + private Bitmap icLauncher; + private Bitmap icDownloadDone; + private Bitmap icDownloadFailed; + + private PendingIntent mOpenDownloadList; + + /** + * notify media scanner on downloaded media file ... + * + * @param file the downloaded file uri + */ + private void notifyMediaScanner(Uri file) { + sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, file)); + } + + @Override + public void onCreate() { + super.onCreate(); + + if (DEBUG) { + Log.d(TAG, "onCreate"); + } + + mBinder = new DownloadManagerBinder(); + mHandler = new Handler(this::handleMessage); + + mPrefs = PreferenceManager.getDefaultSharedPreferences(this); + + mManager = new DownloadManager(this, mHandler, loadMainVideoStorage(), loadMainAudioStorage()); + + Intent openDownloadListIntent = new Intent(this, DownloadActivity.class) + .setAction(Intent.ACTION_MAIN); + + mOpenDownloadList = PendingIntentCompat.getActivity(this, 0, + openDownloadListIntent, + PendingIntent.FLAG_UPDATE_CURRENT, false); + + icLauncher = BitmapFactory.decodeResource(this.getResources(), R.mipmap.ic_launcher); + + Builder builder = new Builder(this, getString(R.string.notification_channel_id)) + .setContentIntent(mOpenDownloadList) + .setSmallIcon(android.R.drawable.stat_sys_download) + .setLargeIcon(icLauncher) + .setContentTitle(getString(R.string.msg_running)) + .setContentText(getString(R.string.msg_running_detail)); + + mNotification = builder.build(); + + mNotificationManager = ContextCompat.getSystemService(this, + NotificationManager.class); + mConnectivityManager = ContextCompat.getSystemService(this, + ConnectivityManager.class); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + mNetworkStateListenerL = new ConnectivityManager.NetworkCallback() { + @Override + public void onAvailable(Network network) { + handleConnectivityState(false); + } + + @Override + public void onLost(Network network) { + handleConnectivityState(false); + } + }; + mConnectivityManager.registerNetworkCallback(new NetworkRequest.Builder().build(), mNetworkStateListenerL); + } else { + mNetworkStateListener = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + handleConnectivityState(false); + } + }; + registerReceiver(mNetworkStateListener, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)); + } + + mPrefs.registerOnSharedPreferenceChangeListener(mPrefChangeListener); + + handlePreferenceChange(mPrefs, getString(R.string.downloads_cross_network)); + handlePreferenceChange(mPrefs, getString(R.string.downloads_maximum_retry)); + handlePreferenceChange(mPrefs, getString(R.string.downloads_queue_limit)); + + mLock = new LockManager(this); + } + + @Override + public int onStartCommand(final Intent intent, int flags, int startId) { + if (DEBUG) { + Log.d(TAG, intent == null ? "Restarting" : "Starting"); + } + + if (intent == null) return START_NOT_STICKY; + + Log.i(TAG, "Got intent: " + intent); + String action = intent.getAction(); + if (action != null) { + if (action.equals(Intent.ACTION_RUN)) { + mHandler.post(() -> startMission(intent)); + } else if (downloadDoneNotification != null) { + if (action.equals(ACTION_RESET_DOWNLOAD_FINISHED) || action.equals(ACTION_OPEN_DOWNLOADS_FINISHED)) { + downloadDoneCount = 0; + downloadDoneList.setLength(0); + } + if (action.equals(ACTION_OPEN_DOWNLOADS_FINISHED)) { + startActivity(new Intent(this, DownloadActivity.class) + .setAction(Intent.ACTION_MAIN) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ); + } + return START_NOT_STICKY; + } + } + + return START_STICKY; + } + + @Override + public void onDestroy() { + super.onDestroy(); + + if (DEBUG) { + Log.d(TAG, "Destroying"); + } + + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE); + + if (mNotificationManager != null && downloadDoneNotification != null) { + downloadDoneNotification.setDeleteIntent(null);// prevent NewPipe running when is killed, cleared from recent, etc + mNotificationManager.notify(DOWNLOADS_NOTIFICATION_ID, downloadDoneNotification.build()); + } + + manageLock(false); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) + mConnectivityManager.unregisterNetworkCallback(mNetworkStateListenerL); + else + unregisterReceiver(mNetworkStateListener); + + mPrefs.unregisterOnSharedPreferenceChangeListener(mPrefChangeListener); + + if (icDownloadDone != null) icDownloadDone.recycle(); + if (icDownloadFailed != null) icDownloadFailed.recycle(); + if (icLauncher != null) icLauncher.recycle(); + + mHandler = null; + mManager.pauseAllMissions(true); + } + + @Override + public IBinder onBind(Intent intent) { + /* + int permissionCheck; + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.JELLY_BEAN) { + permissionCheck = PermissionChecker.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE); + if (permissionCheck == PermissionChecker.PERMISSION_DENIED) { + Toast.makeText(this, "Permission denied (read)", Toast.LENGTH_SHORT).show(); + } + } + + permissionCheck = PermissionChecker.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE); + if (permissionCheck == PermissionChecker.PERMISSION_DENIED) { + Toast.makeText(this, "Permission denied (write)", Toast.LENGTH_SHORT).show(); + } + */ + + return mBinder; + } + + private boolean handleMessage(@NonNull Message msg) { + if (mHandler == null) return true; + + DownloadMission mission = (DownloadMission) msg.obj; + + switch (msg.what) { + case MESSAGE_FINISHED: + notifyMediaScanner(mission.storage.getUri()); + notifyFinishedDownload(mission.storage.getName()); + mManager.setFinished(mission); + handleConnectivityState(false); + updateForegroundState(mManager.runMissions()); + break; + case MESSAGE_RUNNING: + updateForegroundState(true); + break; + case MESSAGE_ERROR: + notifyFailedDownload(mission); + handleConnectivityState(false); + updateForegroundState(mManager.runMissions()); + break; + case MESSAGE_PAUSED: + updateForegroundState(mManager.getRunningMissionsCount() > 0); + break; + } + + if (msg.what != MESSAGE_ERROR) + mFailedDownloads.remove(mFailedDownloads.indexOfValue(mission)); + + for (Callback observer : mEchoObservers) + observer.handleMessage(msg); + + return true; + } + + private void handleConnectivityState(boolean updateOnly) { + NetworkInfo info = mConnectivityManager.getActiveNetworkInfo(); + NetworkState status; + + if (info == null) { + status = NetworkState.Unavailable; + Log.i(TAG, "Active network [connectivity is unavailable]"); + } else { + boolean connected = info.isConnected(); + boolean metered = mConnectivityManager.isActiveNetworkMetered(); + + if (connected) + status = metered ? NetworkState.MeteredOperating : NetworkState.Operating; + else + status = NetworkState.Unavailable; + + Log.i(TAG, "Active network [connected=" + connected + " metered=" + metered + "] " + info.toString()); + } + + if (mManager == null) return;// avoid race-conditions while the service is starting + mManager.handleConnectivityState(status, updateOnly); + } + + private void handlePreferenceChange(SharedPreferences prefs, @NonNull String key) { + if (getString(R.string.downloads_maximum_retry).equals(key)) { + try { + String value = prefs.getString(key, getString(R.string.downloads_maximum_retry_default)); + mManager.mPrefMaxRetry = value == null ? 0 : Integer.parseInt(value); + } catch (Exception e) { + mManager.mPrefMaxRetry = 0; + } + mManager.updateMaximumAttempts(); + } else if (getString(R.string.downloads_cross_network).equals(key)) { + mManager.mPrefMeteredDownloads = prefs.getBoolean(key, false); + } else if (getString(R.string.downloads_queue_limit).equals(key)) { + mManager.mPrefQueueLimit = prefs.getBoolean(key, true); + } else if (getString(R.string.download_path_video_key).equals(key)) { + mManager.mMainStorageVideo = loadMainVideoStorage(); + } else if (getString(R.string.download_path_audio_key).equals(key)) { + mManager.mMainStorageAudio = loadMainAudioStorage(); + } + } + + public void updateForegroundState(boolean state) { + if (state == mForeground) return; + + if (state) { + startForeground(FOREGROUND_NOTIFICATION_ID, mNotification); + } else { + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE); + } + + manageLock(state); + + mForeground = state; + } + + /** + * Start a new download mission + * + * @param context the activity context + * @param urls array of urls to download + * @param storage where the file is saved + * @param kind type of file (a: audio v: video s: subtitle ?: file-extension defined) + * @param threads the number of threads maximal used to download chunks of the file. + * @param psName the name of the required post-processing algorithm, or {@code null} to ignore. + * @param source source url of the resource + * @param psArgs the arguments for the post-processing algorithm. + * @param nearLength the approximated final length of the file + * @param recoveryInfo array of MissionRecoveryInfo, in case is required recover the download + */ + public static void startMission(Context context, String[] urls, StoredFileHelper storage, + char kind, int threads, String source, String psName, + String[] psArgs, long nearLength, + ArrayList recoveryInfo, + VideoSegment[] segments) { + final Intent intent = new Intent(context, DownloadManagerService.class) + .setAction(Intent.ACTION_RUN) + .putExtra(EXTRA_URLS, urls) + .putExtra(EXTRA_KIND, kind) + .putExtra(EXTRA_THREADS, threads) + .putExtra(EXTRA_SOURCE, source) + .putExtra(EXTRA_POSTPROCESSING_NAME, psName) + .putExtra(EXTRA_POSTPROCESSING_ARGS, psArgs) + .putExtra(EXTRA_NEAR_LENGTH, nearLength) + .putExtra(EXTRA_RECOVERY_INFO, recoveryInfo) + .putExtra(EXTRA_PARENT_PATH, storage.getParentUri()) + .putExtra(EXTRA_PATH, storage.getUri()) + .putExtra(EXTRA_SEGMENTS, segments) + .putExtra(EXTRA_STORAGE_TAG, storage.getTag()); + + context.startService(intent); + } + + private void startMission(Intent intent) { + String[] urls = intent.getStringArrayExtra(EXTRA_URLS); + Uri path = IntentCompat.getParcelableExtra(intent, EXTRA_PATH, Uri.class); + Uri parentPath = IntentCompat.getParcelableExtra(intent, EXTRA_PARENT_PATH, Uri.class); + int threads = intent.getIntExtra(EXTRA_THREADS, 1); + char kind = intent.getCharExtra(EXTRA_KIND, '?'); + String psName = intent.getStringExtra(EXTRA_POSTPROCESSING_NAME); + String[] psArgs = intent.getStringArrayExtra(EXTRA_POSTPROCESSING_ARGS); + String source = intent.getStringExtra(EXTRA_SOURCE); + long nearLength = intent.getLongExtra(EXTRA_NEAR_LENGTH, 0); + String tag = intent.getStringExtra(EXTRA_STORAGE_TAG); + final var recovery = IntentCompat.getParcelableArrayListExtra(intent, EXTRA_RECOVERY_INFO, + MissionRecoveryInfo.class); + Objects.requireNonNull(recovery); + + VideoSegment[] segments = null; + if (intent.hasExtra(EXTRA_SEGMENTS) + && intent.getSerializableExtra(EXTRA_SEGMENTS) instanceof VideoSegment[]) { + segments = (VideoSegment[]) intent.getSerializableExtra(EXTRA_SEGMENTS); + } + + StoredFileHelper storage; + try { + storage = new StoredFileHelper(this, parentPath, path, tag); + } catch (IOException e) { + throw new RuntimeException(e);// this never should happen + } + + Postprocessing ps; + if (psName == null) + ps = null; + else + ps = Postprocessing.getAlgorithm(psName, psArgs); + + final DownloadMission mission = new DownloadMission(urls, storage, kind, ps); + mission.threadCount = threads; + mission.source = source; + mission.nearLength = nearLength; + mission.recoveryInfo = recovery.toArray(new MissionRecoveryInfo[0]); + + if (segments != null && segments.length > 0) { + try { + final JsonStringWriter writer = JsonWriter.string() + .object() + .array("segments"); + for (final VideoSegment segment : segments) { + writer.object() + .value("start", segment.startTime) + .value("end", segment.endTime) + .value("category", segment.category) + .end(); + } + writer.end().end(); + mission.segmentsJson = writer.done(); + } catch (final Exception e) { + e.printStackTrace(); + } + } + + if (ps != null) + ps.setTemporalDir(DownloadManager.pickAvailableTemporalDir(this)); + + handleConnectivityState(true);// first check the actual network status + + mManager.startMission(mission); + } + + public void notifyFinishedDownload(String name) { + if (!mDownloadNotificationEnable || mNotificationManager == null) { + return; + } + + if (downloadDoneNotification == null) { + downloadDoneList = new StringBuilder(name.length()); + + icDownloadDone = BitmapFactory.decodeResource(this.getResources(), android.R.drawable.stat_sys_download_done); + downloadDoneNotification = new Builder(this, getString(R.string.notification_channel_id)) + .setAutoCancel(true) + .setLargeIcon(icDownloadDone) + .setSmallIcon(android.R.drawable.stat_sys_download_done) + .setDeleteIntent(makePendingIntent(ACTION_RESET_DOWNLOAD_FINISHED)) + .setContentIntent(makePendingIntent(ACTION_OPEN_DOWNLOADS_FINISHED)); + } + + downloadDoneCount++; + if (downloadDoneCount == 1) { + downloadDoneList.append(name); + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + downloadDoneNotification.setContentTitle(getString(R.string.app_name)); + } else { + downloadDoneNotification.setContentTitle(null); + } + + downloadDoneNotification.setContentText(Localization.downloadCount(this, downloadDoneCount)); + downloadDoneNotification.setStyle(new NotificationCompat.BigTextStyle() + .setBigContentTitle(Localization.downloadCount(this, downloadDoneCount)) + .bigText(name) + ); + } else { + downloadDoneList.append('\n'); + downloadDoneList.append(name); + + downloadDoneNotification.setStyle(new NotificationCompat.BigTextStyle().bigText(downloadDoneList)); + downloadDoneNotification.setContentTitle(Localization.downloadCount(this, downloadDoneCount)); + downloadDoneNotification.setContentText(downloadDoneList); + } + + mNotificationManager.notify(DOWNLOADS_NOTIFICATION_ID, downloadDoneNotification.build()); + } + + public void notifyFailedDownload(DownloadMission mission) { + if (!mDownloadNotificationEnable || mFailedDownloads.containsValue(mission)) return; + + int id = downloadFailedNotificationID++; + mFailedDownloads.put(id, mission); + + if (downloadFailedNotification == null) { + icDownloadFailed = BitmapFactory.decodeResource(this.getResources(), android.R.drawable.stat_sys_warning); + downloadFailedNotification = new Builder(this, getString(R.string.notification_channel_id)) + .setAutoCancel(true) + .setLargeIcon(icDownloadFailed) + .setSmallIcon(android.R.drawable.stat_sys_warning) + .setContentIntent(mOpenDownloadList); + } + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + downloadFailedNotification.setContentTitle(getString(R.string.app_name)); + downloadFailedNotification.setStyle(new NotificationCompat.BigTextStyle() + .bigText(getString(R.string.download_failed).concat(": ").concat(mission.storage.getName()))); + } else { + downloadFailedNotification.setContentTitle(getString(R.string.download_failed)); + downloadFailedNotification.setContentText(mission.storage.getName()); + downloadFailedNotification.setStyle(new NotificationCompat.BigTextStyle() + .bigText(mission.storage.getName())); + } + + mNotificationManager.notify(id, downloadFailedNotification.build()); + } + + private PendingIntent makePendingIntent(String action) { + Intent intent = new Intent(this, DownloadManagerService.class).setAction(action); + return PendingIntentCompat.getService(this, intent.hashCode(), intent, + PendingIntent.FLAG_UPDATE_CURRENT, false); + } + + private void manageLock(boolean acquire) { + if (acquire == mLockAcquired) return; + + if (acquire) + mLock.acquireWifiAndCpu(); + else + mLock.releaseWifiAndCpu(); + + mLockAcquired = acquire; + } + + private StoredDirectoryHelper loadMainVideoStorage() { + return loadMainStorage(R.string.download_path_video_key, DownloadManager.TAG_VIDEO); + } + + private StoredDirectoryHelper loadMainAudioStorage() { + return loadMainStorage(R.string.download_path_audio_key, DownloadManager.TAG_AUDIO); + } + + private StoredDirectoryHelper loadMainStorage(@StringRes int prefKey, String tag) { + String path = mPrefs.getString(getString(prefKey), null); + + if (path == null || path.isEmpty()) return null; + + if (path.charAt(0) == File.separatorChar) { + Log.i(TAG, "Old save path style present: " + path); + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) + path = Uri.fromFile(new File(path)).toString(); + else + path = ""; + + mPrefs.edit().putString(getString(prefKey), "").apply(); + } + + try { + return new StoredDirectoryHelper(this, Uri.parse(path), tag); + } catch (Exception e) { + Log.e(TAG, "Failed to load the storage of " + tag + " from " + path, e); + Toast.makeText(this, R.string.no_available_dir, Toast.LENGTH_LONG).show(); + } + + return null; + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + // Wrappers for DownloadManager + //////////////////////////////////////////////////////////////////////////////////////////////// + + public class DownloadManagerBinder extends Binder { + public DownloadManager getDownloadManager() { + return mManager; + } + + @Nullable + public StoredDirectoryHelper getMainStorageVideo() { + return mManager.mMainStorageVideo; + } + + @Nullable + public StoredDirectoryHelper getMainStorageAudio() { + return mManager.mMainStorageAudio; + } + + public boolean askForSavePath() { + return DownloadManagerService.this.mPrefs.getBoolean( + DownloadManagerService.this.getString(R.string.downloads_storage_ask), + false + ); + } + + public void addMissionEventListener(Callback handler) { + mEchoObservers.add(handler); + } + + public void removeMissionEventListener(Callback handler) { + mEchoObservers.remove(handler); + } + + public void clearDownloadNotifications() { + if (mNotificationManager == null) return; + if (downloadDoneNotification != null) { + mNotificationManager.cancel(DOWNLOADS_NOTIFICATION_ID); + downloadDoneList.setLength(0); + downloadDoneCount = 0; + } + if (downloadFailedNotification != null) { + for (; downloadFailedNotificationID > DOWNLOADS_NOTIFICATION_ID; downloadFailedNotificationID--) { + mNotificationManager.cancel(downloadFailedNotificationID); + } + mFailedDownloads.clear(); + downloadFailedNotificationID++; + } + } + + public void enableNotifications(boolean enable) { + mDownloadNotificationEnable = enable; + } + + } + +} diff --git a/app/src/braveLegacy/java/us/shandian/giga/ui/adapter/MissionAdapter.java b/app/src/braveLegacy/java/us/shandian/giga/ui/adapter/MissionAdapter.java new file mode 100644 index 0000000000..08e072653b --- /dev/null +++ b/app/src/braveLegacy/java/us/shandian/giga/ui/adapter/MissionAdapter.java @@ -0,0 +1,1017 @@ +package us.shandian.giga.ui.adapter; + +import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK; +import static android.content.Intent.FLAG_GRANT_PREFIX_URI_PERMISSION; +import static android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION; +import static us.shandian.giga.get.DownloadMission.ERROR_CONNECT_HOST; +import static us.shandian.giga.get.DownloadMission.ERROR_FILE_CREATION; +import static us.shandian.giga.get.DownloadMission.ERROR_HTTP_NO_CONTENT; +import static us.shandian.giga.get.DownloadMission.ERROR_INSUFFICIENT_STORAGE; +import static us.shandian.giga.get.DownloadMission.ERROR_NOTHING; +import static us.shandian.giga.get.DownloadMission.ERROR_PATH_CREATION; +import static us.shandian.giga.get.DownloadMission.ERROR_PERMISSION_DENIED; +import static us.shandian.giga.get.DownloadMission.ERROR_POSTPROCESSING; +import static us.shandian.giga.get.DownloadMission.ERROR_POSTPROCESSING_HOLD; +import static us.shandian.giga.get.DownloadMission.ERROR_POSTPROCESSING_STOPPED; +import static us.shandian.giga.get.DownloadMission.ERROR_PROGRESS_LOST; +import static us.shandian.giga.get.DownloadMission.ERROR_RESOURCE_GONE; +import static us.shandian.giga.get.DownloadMission.ERROR_SSL_EXCEPTION; +import static us.shandian.giga.get.DownloadMission.ERROR_TIMEOUT; +import static us.shandian.giga.get.DownloadMission.ERROR_UNKNOWN_EXCEPTION; +import static us.shandian.giga.get.DownloadMission.ERROR_UNKNOWN_HOST; + +import android.annotation.SuppressLint; +import android.app.NotificationManager; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.graphics.Color; +import android.net.Uri; +import android.os.Build; +import android.os.Handler; +import android.os.Message; +import android.util.Log; +import android.view.HapticFeedbackConstants; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.webkit.MimeTypeMap; +import android.widget.ImageView; +import android.widget.PopupMenu; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; +import androidx.appcompat.app.AlertDialog; +import androidx.core.app.NotificationCompat; +import androidx.core.content.ContextCompat; +import androidx.core.content.FileProvider; +import androidx.core.os.HandlerCompat; +import androidx.preference.PreferenceManager; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.RecyclerView.Adapter; +import androidx.recyclerview.widget.RecyclerView.ViewHolder; + +import com.google.android.material.snackbar.Snackbar; + +import org.schabi.newpipe.App; +import org.schabi.newpipe.BuildConfig; +import org.schabi.newpipe.LocalPlayerActivity; +import org.schabi.newpipe.R; +import org.schabi.newpipe.error.ErrorInfo; +import org.schabi.newpipe.error.ErrorUtil; +import org.schabi.newpipe.error.UserAction; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.streams.io.StoredFileHelper; +import org.schabi.newpipe.util.Localization; +import org.schabi.newpipe.util.NavigationHelper; +import org.schabi.newpipe.util.external_communication.ShareUtils; + +import java.io.File; +import java.net.URI; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Iterator; + +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; +import io.reactivex.rxjava3.core.Observable; +import io.reactivex.rxjava3.disposables.CompositeDisposable; +import io.reactivex.rxjava3.schedulers.Schedulers; +import us.shandian.giga.get.DownloadMission; +import us.shandian.giga.get.FinishedMission; +import us.shandian.giga.get.Mission; +import us.shandian.giga.get.MissionRecoveryInfo; +import us.shandian.giga.service.DownloadManager; +import us.shandian.giga.service.DownloadManagerService; +import us.shandian.giga.ui.common.Deleter; +import us.shandian.giga.ui.common.ProgressDrawable; +import us.shandian.giga.util.Utility; + +public class MissionAdapter extends Adapter implements Handler.Callback { + private static final String TAG = "MissionAdapter"; + private static final String UNDEFINED_PROGRESS = "--.-%"; + private static final String DEFAULT_MIME_TYPE = "*/*"; + private static final String UNDEFINED_ETA = "--:--"; + + private static final String UPDATER = "updater"; + private static final String DELETE = "deleteFinishedDownloads"; + + private static final int HASH_NOTIFICATION_ID = 123790; + + private final Context mContext; + private final LayoutInflater mInflater; + private final DownloadManager mDownloadManager; + private final Deleter mDeleter; + private int mLayout; + private final DownloadManager.MissionIterator mIterator; + private final ArrayList mPendingDownloadsItems = new ArrayList<>(); + private final Handler mHandler; + private MenuItem mClear; + private MenuItem mStartButton; + private MenuItem mPauseButton; + private final View mEmptyMessage; + private RecoverHelper mRecover; + private final View mView; + private final ArrayList mHidden; + private Snackbar mSnackbar; + + private final CompositeDisposable compositeDisposable = new CompositeDisposable(); + + private SharedPreferences mPrefs; + + public MissionAdapter(Context context, @NonNull DownloadManager downloadManager, View emptyMessage, View root) { + mContext = context; + mPrefs = PreferenceManager.getDefaultSharedPreferences(App.getApp()); + + mDownloadManager = downloadManager; + + mInflater = LayoutInflater.from(mContext); + mLayout = R.layout.mission_item; + + mHandler = new Handler(context.getMainLooper()); + + mEmptyMessage = emptyMessage; + + mIterator = downloadManager.getIterator(); + + mDeleter = new Deleter(root, mContext, this, mDownloadManager, mIterator, mHandler); + + mView = root; + + mHidden = new ArrayList<>(); + + checkEmptyMessageVisibility(); + onResume(); + } + + @Override + @NonNull + public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + switch (viewType) { + case DownloadManager.SPECIAL_PENDING: + case DownloadManager.SPECIAL_FINISHED: + return new ViewHolderHeader(mInflater.inflate(R.layout.missions_header, parent, false)); + } + + return new ViewHolderItem(mInflater.inflate(mLayout, parent, false)); + } + + @Override + public void onViewRecycled(@NonNull ViewHolder view) { + super.onViewRecycled(view); + + if (view instanceof ViewHolderHeader) return; + ViewHolderItem h = (ViewHolderItem) view; + + if (h.item.mission instanceof DownloadMission) { + mPendingDownloadsItems.remove(h); + if (mPendingDownloadsItems.size() < 1) { + checkMasterButtonsVisibility(); + } + } + + h.popupMenu.dismiss(); + h.item = null; + h.resetSpeedMeasure(); + } + + @Override + @SuppressLint("SetTextI18n") + public void onBindViewHolder(@NonNull ViewHolder view, @SuppressLint("RecyclerView") int pos) { + DownloadManager.MissionItem item = mIterator.getItem(pos); + + if (view instanceof ViewHolderHeader) { + if (item.special == DownloadManager.SPECIAL_NOTHING) return; + int str; + if (item.special == DownloadManager.SPECIAL_PENDING) { + str = R.string.missions_header_pending; + } else { + str = R.string.missions_header_finished; + if (mClear != null) mClear.setVisible(true); + } + + ((ViewHolderHeader) view).header.setText(str); + return; + } + + ViewHolderItem h = (ViewHolderItem) view; + h.item = item; + + Utility.FileType type = Utility.getFileType(item.mission.kind, item.mission.storage.getName()); + + h.icon.setImageResource(Utility.getIconForFileType(type)); + h.name.setText(item.mission.storage.getName()); + + h.progress.setColors(Utility.getBackgroundForFileType(mContext, type), Utility.getForegroundForFileType(mContext, type)); + + if (h.item.mission instanceof DownloadMission) { + DownloadMission mission = (DownloadMission) item.mission; + String length = Utility.formatBytes(mission.getLength()); + if (mission.running && !mission.isPsRunning()) length += " --.- kB/s"; + + h.size.setText(length); + h.pause.setTitle(mission.unknownLength ? R.string.stop : R.string.pause); + updateProgress(h); + mPendingDownloadsItems.add(h); + } else { + h.progress.setMarquee(false); + h.status.setText("100%"); + h.progress.setProgress(1.0f); + h.size.setText(Utility.formatBytes(item.mission.length)); + } + } + + @Override + public int getItemCount() { + return mIterator.getOldListSize(); + } + + @Override + public int getItemViewType(int position) { + return mIterator.getSpecialAtItem(position); + } + + @SuppressLint("DefaultLocale") + private void updateProgress(ViewHolderItem h) { + if (h == null || h.item == null || h.item.mission instanceof FinishedMission) return; + + DownloadMission mission = (DownloadMission) h.item.mission; + double done = mission.done; + long length = mission.getLength(); + long now = System.currentTimeMillis(); + boolean hasError = mission.errCode != ERROR_NOTHING; + + // hide on error + // show if current resource length is not fetched + // show if length is unknown + h.progress.setMarquee(mission.isRecovering() || !hasError && (!mission.isInitialized() || mission.unknownLength)); + + double progress; + if (mission.unknownLength) { + progress = Double.NaN; + h.progress.setProgress(0.0f); + } else { + progress = done / length; + } + + if (hasError) { + h.progress.setProgress(isNotFinite(progress) ? 1d : progress); + h.status.setText(R.string.msg_error); + } else if (isNotFinite(progress)) { + h.status.setText(UNDEFINED_PROGRESS); + } else { + h.status.setText(String.format("%.2f%%", progress * 100)); + h.progress.setProgress(progress); + } + + @StringRes int state; + String sizeStr = Utility.formatBytes(length).concat(" "); + + if (mission.isPsFailed() || mission.errCode == ERROR_POSTPROCESSING_HOLD) { + h.size.setText(sizeStr); + return; + } else if (!mission.running) { + state = mission.enqueued ? R.string.queued : R.string.paused; + } else if (mission.isPsRunning()) { + state = R.string.post_processing; + } else if (mission.isRecovering()) { + state = R.string.recovering; + } else { + state = 0; + } + + if (state != 0) { + // update state without download speed + h.size.setText(sizeStr.concat("(").concat(mContext.getString(state)).concat(")")); + h.resetSpeedMeasure(); + return; + } + + if (h.lastTimestamp < 0) { + h.size.setText(sizeStr); + h.lastTimestamp = now; + h.lastDone = done; + return; + } + + long deltaTime = now - h.lastTimestamp; + double deltaDone = done - h.lastDone; + + if (h.lastDone > done) { + h.lastDone = done; + h.size.setText(sizeStr); + return; + } + + if (deltaDone > 0 && deltaTime > 0) { + float speed = (float) ((deltaDone * 1000d) / deltaTime); + float averageSpeed = speed; + + if (h.lastSpeedIdx < 0) { + Arrays.fill(h.lastSpeed, speed); + h.lastSpeedIdx = 0; + } else { + for (int i = 0; i < h.lastSpeed.length; i++) { + averageSpeed += h.lastSpeed[i]; + } + averageSpeed /= h.lastSpeed.length + 1.0f; + } + + String speedStr = Utility.formatSpeed(averageSpeed); + String etaStr; + + if (mission.unknownLength) { + etaStr = ""; + } else { + long eta = (long) Math.ceil((length - done) / averageSpeed); + etaStr = Utility.formatBytes((long) done) + "/" + Utility.stringifySeconds(eta) + " "; + } + + h.size.setText(sizeStr.concat(etaStr).concat(speedStr)); + + h.lastTimestamp = now; + h.lastDone = done; + h.lastSpeed[h.lastSpeedIdx++] = speed; + + if (h.lastSpeedIdx >= h.lastSpeed.length) h.lastSpeedIdx = 0; + } + } + + private void open(Mission mission) { + if (checkInvalidFile(mission)) return; + + String mimeType = resolveMimeType(mission); + + if (BuildConfig.DEBUG) + Log.v(TAG, "Mime: " + mimeType + " package: " + BuildConfig.APPLICATION_ID + ".provider"); + + Uri uri = resolveShareableUri(mission); + + Intent intent = new Intent(mContext, LocalPlayerActivity.class); + intent.setDataAndType(uri, mimeType); + intent.putExtra("segments", mission.segmentsJson); + intent.setFlags(FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_REORDER_TO_FRONT); + + mContext.startActivity(intent); + } + + private void openExternally(Mission mission) { + if (checkInvalidFile(mission)) return; + + String mimeType = resolveMimeType(mission); + + if (BuildConfig.DEBUG) + Log.v(TAG, "Mime: " + mimeType + " package: " + BuildConfig.APPLICATION_ID + ".provider"); + + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setDataAndType(resolveShareableUri(mission), mimeType); + intent.addFlags(FLAG_GRANT_READ_URI_PERMISSION); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + intent.addFlags(FLAG_GRANT_PREFIX_URI_PERMISSION); + } + + ShareUtils.openIntentInApp(mContext, intent); + } + + private void shareFile(Mission mission) { + if (checkInvalidFile(mission)) return; + + final Intent shareIntent = new Intent(Intent.ACTION_SEND); + shareIntent.setType(resolveMimeType(mission)); + shareIntent.putExtra(Intent.EXTRA_STREAM, resolveShareableUri(mission)); + shareIntent.addFlags(FLAG_GRANT_READ_URI_PERMISSION); + + final Intent intent = new Intent(Intent.ACTION_CHOOSER); + intent.putExtra(Intent.EXTRA_INTENT, shareIntent); + // unneeded to set a title to the chooser on Android P and higher because the system + // ignores this title on these versions + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.O_MR1) { + intent.putExtra(Intent.EXTRA_TITLE, mContext.getString(R.string.share_dialog_title)); + } + intent.addFlags(FLAG_ACTIVITY_NEW_TASK); + intent.addFlags(FLAG_GRANT_READ_URI_PERMISSION); + + mContext.startActivity(intent); + } + + /** + * Returns an Uri which can be shared to other applications. + * + * @see + * https://stackoverflow.com/questions/38200282/android-os-fileuriexposedexception-file-storage-emulated-0-test-txt-exposed + */ + private Uri resolveShareableUri(Mission mission) { + if (mission.storage.isDirect()) { + return FileProvider.getUriForFile( + mContext, + BuildConfig.APPLICATION_ID + ".provider", + new File(URI.create(mission.storage.getUri().toString())) + ); + } else { + return mission.storage.getUri(); + } + } + + private static String resolveMimeType(@NonNull Mission mission) { + String mimeType; + + if (!mission.storage.isInvalid()) { + mimeType = mission.storage.getType(); + if (mimeType != null && mimeType.length() > 0 && !mimeType.equals(StoredFileHelper.DEFAULT_MIME)) + return mimeType; + } + + String ext = Utility.getFileExt(mission.storage.getName()); + if (ext == null) return DEFAULT_MIME_TYPE; + + mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext.substring(1)); + + return mimeType == null ? DEFAULT_MIME_TYPE : mimeType; + } + + private boolean checkInvalidFile(@NonNull Mission mission) { + if (mission.storage.existsAsFile()) return false; + + Toast.makeText(mContext, R.string.missing_file, Toast.LENGTH_SHORT).show(); + return true; + } + + private ViewHolderItem getViewHolder(Object mission) { + for (ViewHolderItem h : mPendingDownloadsItems) { + if (h.item.mission == mission) return h; + } + return null; + } + + @Override + public boolean handleMessage(@NonNull Message msg) { + if (mStartButton != null && mPauseButton != null) { + checkMasterButtonsVisibility(); + } + + switch (msg.what) { + case DownloadManagerService.MESSAGE_ERROR: + case DownloadManagerService.MESSAGE_FINISHED: + case DownloadManagerService.MESSAGE_DELETED: + case DownloadManagerService.MESSAGE_PAUSED: + break; + default: + return false; + } + + ViewHolderItem h = getViewHolder(msg.obj); + if (h == null) return false; + + switch (msg.what) { + case DownloadManagerService.MESSAGE_FINISHED: + case DownloadManagerService.MESSAGE_DELETED: + // DownloadManager should mark the download as finished + applyChanges(); + return true; + } + + updateProgress(h); + return true; + } + + private void showError(@NonNull DownloadMission mission) { + @StringRes int msg = R.string.general_error; + String msgEx = null; + + switch (mission.errCode) { + case 416: + msg = R.string.error_http_unsupported_range; + break; + case 404: + msg = R.string.error_http_not_found; + break; + case ERROR_NOTHING: + return;// this never should happen + case ERROR_FILE_CREATION: + msg = R.string.error_file_creation; + break; + case ERROR_HTTP_NO_CONTENT: + msg = R.string.error_http_no_content; + break; + case ERROR_PATH_CREATION: + msg = R.string.error_path_creation; + break; + case ERROR_PERMISSION_DENIED: + msg = R.string.permission_denied; + break; + case ERROR_SSL_EXCEPTION: + msg = R.string.error_ssl_exception; + break; + case ERROR_UNKNOWN_HOST: + msg = R.string.error_unknown_host; + break; + case ERROR_CONNECT_HOST: + msg = R.string.error_connect_host; + break; + case ERROR_POSTPROCESSING_STOPPED: + msg = R.string.error_postprocessing_stopped; + break; + case ERROR_POSTPROCESSING: + case ERROR_POSTPROCESSING_HOLD: + showError(mission, UserAction.DOWNLOAD_POSTPROCESSING, R.string.error_postprocessing_failed); + return; + case ERROR_INSUFFICIENT_STORAGE: + msg = R.string.error_insufficient_storage_left; + break; + case ERROR_UNKNOWN_EXCEPTION: + if (mission.errObject != null) { + showError(mission, UserAction.DOWNLOAD_FAILED, R.string.general_error); + return; + } else { + msg = R.string.msg_error; + break; + } + case ERROR_PROGRESS_LOST: + msg = R.string.error_progress_lost; + break; + case ERROR_TIMEOUT: + msg = R.string.error_timeout; + break; + case ERROR_RESOURCE_GONE: + msg = R.string.error_download_resource_gone; + break; + default: + if (mission.errCode >= 100 && mission.errCode < 600) { + msgEx = "HTTP " + mission.errCode; + } else if (mission.errObject == null) { + msgEx = "(not_decelerated_error_code)"; + } else { + showError(mission, UserAction.DOWNLOAD_FAILED, msg); + return; + } + break; + } + + AlertDialog.Builder builder = new AlertDialog.Builder(mContext); + + if (msgEx != null) + builder.setMessage(msgEx); + else + builder.setMessage(msg); + + // add report button for non-HTTP errors (range 100-599) + if (mission.errObject != null && (mission.errCode < 100 || mission.errCode >= 600)) { + @StringRes final int mMsg = msg; + builder.setPositiveButton(R.string.error_report_title, (dialog, which) -> + showError(mission, UserAction.DOWNLOAD_FAILED, mMsg) + ); + } + + builder.setNegativeButton(R.string.ok, (dialog, which) -> dialog.cancel()) + .setTitle(mission.storage.getName()) + .show(); + } + + private void showError(DownloadMission mission, UserAction action, @StringRes int reason) { + StringBuilder request = new StringBuilder(256); + request.append(mission.source); + + request.append(" ["); + if (mission.recoveryInfo != null) { + for (MissionRecoveryInfo recovery : mission.recoveryInfo) + request.append(' ') + .append(recovery.toString()) + .append(' '); + } + request.append("]"); + + String service; + try { + service = NewPipe.getServiceByUrl(mission.source).getServiceInfo().getName(); + } catch (Exception e) { + service = ErrorInfo.SERVICE_NONE; + } + + ErrorUtil.createNotification(mContext, + new ErrorInfo(ErrorInfo.Companion.throwableToStringList(mission.errObject), action, + service, request.toString(), reason)); + } + + public void clearFinishedDownloads(boolean delete) { + if (delete && mIterator.hasFinishedMissions() && mHidden.isEmpty()) { + for (int i = 0; i < mIterator.getOldListSize(); i++) { + FinishedMission mission = mIterator.getItem(i).mission instanceof FinishedMission ? (FinishedMission) mIterator.getItem(i).mission : null; + if (mission != null) { + mIterator.hide(mission); + mHidden.add(mission); + } + } + applyChanges(); + + String msg = Localization.deletedDownloadCount(mContext, mHidden.size()); + mSnackbar = Snackbar.make(mView, msg, Snackbar.LENGTH_INDEFINITE); + mSnackbar.setAction(R.string.undo, s -> { + Iterator i = mHidden.iterator(); + while (i.hasNext()) { + mIterator.unHide(i.next()); + i.remove(); + } + applyChanges(); + mHandler.removeCallbacksAndMessages(DELETE); + }); + mSnackbar.setActionTextColor(Color.YELLOW); + mSnackbar.show(); + + HandlerCompat.postDelayed(mHandler, this::deleteFinishedDownloads, DELETE, 5000); + } else if (!delete) { + mDownloadManager.forgetFinishedDownloads(); + applyChanges(); + } + } + + private void deleteFinishedDownloads() { + if (mSnackbar != null) mSnackbar.dismiss(); + + Iterator i = mHidden.iterator(); + while (i.hasNext()) { + Mission mission = i.next(); + if (mission != null) { + mDownloadManager.deleteMission(mission); + mContext.sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, mission.storage.getUri())); + } + i.remove(); + } + } + + private boolean handlePopupItem(@NonNull ViewHolderItem h, @NonNull MenuItem option) { + if (h.item == null) return true; + + int id = option.getItemId(); + DownloadMission mission = h.item.mission instanceof DownloadMission ? (DownloadMission) h.item.mission : null; + + if (mission != null) { + switch (id) { + case R.id.start: + h.status.setText(UNDEFINED_PROGRESS); + mDownloadManager.resumeMission(mission); + return true; + case R.id.pause: + mDownloadManager.pauseMission(mission); + return true; + case R.id.error_message_view: + showError(mission); + return true; + case R.id.queue: + boolean flag = !h.queue.isChecked(); + h.queue.setChecked(flag); + mission.setEnqueued(flag); + updateProgress(h); + return true; + case R.id.retry: + if (mission.isPsRunning()) { + mission.psContinue(true); + } else { + mDownloadManager.tryRecover(mission); + if (mission.storage.isInvalid()) + mRecover.tryRecover(mission); + else + recoverMission(mission); + } + return true; + case R.id.cancel: + mission.psContinue(false); + return false; + } + } + + switch (id) { + case R.id.menu_item_share: + shareFile(h.item.mission); + return true; + case R.id.delete: + mDeleter.append(h.item.mission); + applyChanges(); + checkMasterButtonsVisibility(); + return true; + case R.id.open_externally: + openExternally(h.item.mission); + return true; + case R.id.md5: + case R.id.sha1: + final NotificationManager notificationManager + = ContextCompat.getSystemService(mContext, NotificationManager.class); + final NotificationCompat.Builder progressNotificationBuilder + = new NotificationCompat.Builder(mContext, + mContext.getString(R.string.hash_channel_id)) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setSmallIcon(R.drawable.ic_newpipe_triangle_white) + .setContentTitle(mContext.getString(R.string.msg_calculating_hash)) + .setContentText(mContext.getString(R.string.msg_wait)) + .setProgress(0, 0, true) + .setOngoing(true); + + notificationManager.notify(HASH_NOTIFICATION_ID, progressNotificationBuilder + .build()); + final StoredFileHelper storage = h.item.mission.storage; + compositeDisposable.add( + Observable.fromCallable(() -> Utility.checksum(storage, id)) + .subscribeOn(Schedulers.computation()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(result -> { + ShareUtils.copyToClipboard(mContext, result); + notificationManager.cancel(HASH_NOTIFICATION_ID); + }) + ); + return true; + case R.id.source: + /*Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(h.item.mission.source)); + mContext.startActivity(intent);*/ + try { + Intent intent = NavigationHelper.getIntentByLink(mContext, h.item.mission.source); + intent.addFlags(Intent.FLAG_ACTIVITY_PREVIOUS_IS_TOP); + mContext.startActivity(intent); + } catch (Exception e) { + Log.w(TAG, "Selected item has a invalid source", e); + } + return true; + default: + return false; + } + } + + public void applyChanges() { + mIterator.start(); + DiffUtil.calculateDiff(mIterator, true).dispatchUpdatesTo(this); + mIterator.end(); + + checkEmptyMessageVisibility(); + if (mClear != null) mClear.setVisible(mIterator.hasFinishedMissions()); + } + + public void forceUpdate() { + mIterator.start(); + mIterator.end(); + + for (ViewHolderItem item : mPendingDownloadsItems) { + item.resetSpeedMeasure(); + } + + notifyDataSetChanged(); + } + + public void setLinear(boolean isLinear) { + mLayout = isLinear ? R.layout.mission_item_linear : R.layout.mission_item; + } + + public void setClearButton(MenuItem clearButton) { + if (mClear == null) + clearButton.setVisible(mIterator.hasFinishedMissions()); + + mClear = clearButton; + } + + public void setMasterButtons(MenuItem startButton, MenuItem pauseButton) { + boolean init = mStartButton == null || mPauseButton == null; + + mStartButton = startButton; + mPauseButton = pauseButton; + + if (init) checkMasterButtonsVisibility(); + } + + private void checkEmptyMessageVisibility() { + int flag = mIterator.getOldListSize() > 0 ? View.GONE : View.VISIBLE; + if (mEmptyMessage.getVisibility() != flag) mEmptyMessage.setVisibility(flag); + } + + public void checkMasterButtonsVisibility() { + boolean[] state = mIterator.hasValidPendingMissions(); + Log.d(TAG, "checkMasterButtonsVisibility() running=" + state[0] + " paused=" + state[1]); + setButtonVisible(mPauseButton, state[0]); + setButtonVisible(mStartButton, state[1]); + } + + private static void setButtonVisible(MenuItem button, boolean visible) { + if (button.isVisible() != visible) + button.setVisible(visible); + } + + public void refreshMissionItems() { + for (ViewHolderItem h : mPendingDownloadsItems) { + if (((DownloadMission) h.item.mission).running) continue; + updateProgress(h); + h.resetSpeedMeasure(); + } + } + + public void onDestroy() { + compositeDisposable.dispose(); + mDeleter.dispose(); + } + + public void onResume() { + mDeleter.resume(); + HandlerCompat.postDelayed(mHandler, this::updater, UPDATER, 0); + } + + public void onPaused() { + mDeleter.pause(); + mHandler.removeCallbacksAndMessages(UPDATER); + } + + public void recoverMission(DownloadMission mission) { + ViewHolderItem h = getViewHolder(mission); + if (h == null) return; + + mission.errObject = null; + mission.resetState(true, false, DownloadMission.ERROR_NOTHING); + + h.status.setText(UNDEFINED_PROGRESS); + h.size.setText(Utility.formatBytes(mission.getLength())); + h.progress.setMarquee(true); + + mDownloadManager.resumeMission(mission); + } + + private void updater() { + for (ViewHolderItem h : mPendingDownloadsItems) { + // check if the mission is running first + if (!((DownloadMission) h.item.mission).running) continue; + + updateProgress(h); + } + + HandlerCompat.postDelayed(mHandler, this::updater, UPDATER, 1000); + } + + private boolean isNotFinite(double value) { + return Double.isNaN(value) || Double.isInfinite(value); + } + + public void setRecover(@NonNull RecoverHelper callback) { + mRecover = callback; + } + + + class ViewHolderItem extends RecyclerView.ViewHolder { + DownloadManager.MissionItem item; + + TextView status; + ImageView icon; + TextView name; + TextView size; + ProgressDrawable progress; + + PopupMenu popupMenu; + MenuItem retry; + MenuItem cancel; + MenuItem start; + MenuItem pause; + MenuItem open; + MenuItem queue; + MenuItem showError; + MenuItem delete; + MenuItem source; + MenuItem checksum; + + long lastTimestamp = -1; + double lastDone; + int lastSpeedIdx; + float[] lastSpeed = new float[3]; + String estimatedTimeArrival = UNDEFINED_ETA; + + ViewHolderItem(View view) { + super(view); + + progress = new ProgressDrawable(); + itemView.findViewById(R.id.item_bkg).setBackground(progress); + + status = itemView.findViewById(R.id.item_status); + name = itemView.findViewById(R.id.item_name); + icon = itemView.findViewById(R.id.item_icon); + size = itemView.findViewById(R.id.item_size); + + name.setSelected(true); + + ImageView button = itemView.findViewById(R.id.item_more); + popupMenu = buildPopup(button); + button.setOnClickListener(v -> showPopupMenu()); + + Menu menu = popupMenu.getMenu(); + retry = menu.findItem(R.id.retry); + cancel = menu.findItem(R.id.cancel); + start = menu.findItem(R.id.start); + pause = menu.findItem(R.id.pause); + open = menu.findItem(R.id.menu_item_share); + queue = menu.findItem(R.id.queue); + showError = menu.findItem(R.id.error_message_view); + delete = menu.findItem(R.id.delete); + source = menu.findItem(R.id.source); + checksum = menu.findItem(R.id.checksum); + + itemView.setHapticFeedbackEnabled(true); + + itemView.setOnClickListener(v -> { + if (item.mission instanceof FinishedMission) { + if (mPrefs.getBoolean(mContext + .getString(R.string.enable_local_player_key), false)) { + open(item.mission); + } else { + openExternally(item.mission); + } + } + }); + + itemView.setOnLongClickListener(v -> { + v.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS); + showPopupMenu(); + return true; + }); + } + + private void showPopupMenu() { + retry.setVisible(false); + cancel.setVisible(false); + start.setVisible(false); + pause.setVisible(false); + open.setVisible(false); + queue.setVisible(false); + showError.setVisible(false); + delete.setVisible(false); + source.setVisible(false); + checksum.setVisible(false); + + DownloadMission mission = item.mission instanceof DownloadMission ? (DownloadMission) item.mission : null; + + if (mission != null) { + if (mission.hasInvalidStorage()) { + retry.setVisible(true); + delete.setVisible(true); + showError.setVisible(true); + } else if (mission.isPsRunning()) { + switch (mission.errCode) { + case ERROR_INSUFFICIENT_STORAGE: + case ERROR_POSTPROCESSING_HOLD: + retry.setVisible(true); + cancel.setVisible(true); + showError.setVisible(true); + break; + } + } else { + if (mission.running) { + pause.setVisible(true); + } else { + if (mission.errCode != ERROR_NOTHING) { + showError.setVisible(true); + } + + queue.setChecked(mission.enqueued); + + delete.setVisible(true); + + boolean flag = !mission.isPsFailed() && mission.urls.length > 0; + start.setVisible(flag); + queue.setVisible(flag); + } + } + } else { + open.setVisible(true); + delete.setVisible(true); + checksum.setVisible(true); + } + + if (item.mission.source != null && !item.mission.source.isEmpty()) { + source.setVisible(true); + } + + popupMenu.show(); + } + + private PopupMenu buildPopup(final View button) { + PopupMenu popup = new PopupMenu(mContext, button); + popup.inflate(R.menu.mission); + popup.setOnMenuItemClickListener(option -> handlePopupItem(this, option)); + + return popup; + } + + private void resetSpeedMeasure() { + estimatedTimeArrival = UNDEFINED_ETA; + lastTimestamp = -1; + lastSpeedIdx = -1; + } + } + + static class ViewHolderHeader extends RecyclerView.ViewHolder { + TextView header; + + ViewHolderHeader(View view) { + super(view); + header = itemView.findViewById(R.id.item_name); + } + } + + public interface RecoverHelper { + void tryRecover(DownloadMission mission); + } +} diff --git a/app/src/braveLegacy/res/raw/ca_digicert_global_g2 b/app/src/braveLegacy/res/raw/ca_digicert_global_g2 new file mode 100644 index 0000000000..22e48e7f05 --- /dev/null +++ b/app/src/braveLegacy/res/raw/ca_digicert_global_g2 @@ -0,0 +1,28 @@ +-----BEGIN CERTIFICATE----- +MIIEyDCCA7CgAwIBAgIQDPW9BitWAvR6uFAsI8zwZjANBgkqhkiG9w0BAQsFADBh +MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 +d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBH +MjAeFw0yMTAzMzAwMDAwMDBaFw0zMTAzMjkyMzU5NTlaMFkxCzAJBgNVBAYTAlVT +MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxMzAxBgNVBAMTKkRpZ2lDZXJ0IEdsb2Jh +bCBHMiBUTFMgUlNBIFNIQTI1NiAyMDIwIENBMTCCASIwDQYJKoZIhvcNAQEBBQAD +ggEPADCCAQoCggEBAMz3EGJPprtjb+2QUlbFbSd7ehJWivH0+dbn4Y+9lavyYEEV +cNsSAPonCrVXOFt9slGTcZUOakGUWzUb+nv6u8W+JDD+Vu/E832X4xT1FE3LpxDy +FuqrIvAxIhFhaZAmunjZlx/jfWardUSVc8is/+9dCopZQ+GssjoP80j812s3wWPc +3kbW20X+fSP9kOhRBx5Ro1/tSUZUfyyIxfQTnJcVPAPooTncaQwywa8WV0yUR0J8 +osicfebUTVSvQpmowQTCd5zWSOTOEeAqgJnwQ3DPP3Zr0UxJqyRewg2C/Uaoq2yT +zGJSQnWS+Jr6Xl6ysGHlHx+5fwmY6D36g39HaaECAwEAAaOCAYIwggF+MBIGA1Ud +EwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFHSFgMBmx9833s+9KTeqAx2+7c0XMB8G +A1UdIwQYMBaAFE4iVCAYlebjbuYP+vq5Eu0GF485MA4GA1UdDwEB/wQEAwIBhjAd +BgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwdgYIKwYBBQUHAQEEajBoMCQG +CCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20wQAYIKwYBBQUHMAKG +NGh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEdsb2JhbFJvb3RH +Mi5jcnQwQgYDVR0fBDswOTA3oDWgM4YxaHR0cDovL2NybDMuZGlnaWNlcnQuY29t +L0RpZ2lDZXJ0R2xvYmFsUm9vdEcyLmNybDA9BgNVHSAENjA0MAsGCWCGSAGG/WwC +ATAHBgVngQwBATAIBgZngQwBAgEwCAYGZ4EMAQICMAgGBmeBDAECAzANBgkqhkiG +9w0BAQsFAAOCAQEAkPFwyyiXaZd8dP3A+iZ7U6utzWX9upwGnIrXWkOH7U1MVl+t +wcW1BSAuWdH/SvWgKtiwla3JLko716f2b4gp/DA/JIS7w7d7kwcsr4drdjPtAFVS +slme5LnQ89/nD/7d+MS5EHKBCQRfz5eeLjJ1js+aWNJXMX43AYGyZm0pGrFmCW3R +bpD0ufovARTFXFZkAdl9h6g4U5+LXUZtXMYnhIHUfoyMo5tS58aI7Dd8KvvwVVo4 +chDYABPPTHPbqjc1qCmBaZx2vN4Ye5DUys/vZwP9BFohFrH/6j/f3IL16/RZkiMN +JCqVJUzKoZHm1Lesh3Sz8W2jmdv51b2EQJ8HmA== +-----END CERTIFICATE----- diff --git a/app/src/braveLegacy/res/raw/ca_lets_encrypt_root b/app/src/braveLegacy/res/raw/ca_lets_encrypt_root new file mode 100644 index 0000000000..b85c8037f6 --- /dev/null +++ b/app/src/braveLegacy/res/raw/ca_lets_encrypt_root @@ -0,0 +1,31 @@ +-----BEGIN CERTIFICATE----- +MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw +TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh +cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4 +WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu +ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY +MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc +h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+ +0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U +A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW +T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH +B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC +B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv +KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn +OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn +jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw +qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI +rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV +HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq +hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL +ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ +3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK +NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5 +ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur +TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC +jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc +oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq +4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA +mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d +emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc= +-----END CERTIFICATE----- diff --git a/app/src/braveLegacy/res/values-ar/strings.xml b/app/src/braveLegacy/res/values-ar/strings.xml new file mode 100644 index 0000000000..0e34bcfdc6 --- /dev/null +++ b/app/src/braveLegacy/res/values-ar/strings.xml @@ -0,0 +1,4 @@ + + + \"Storage Access Framework\" غير مدعوم على Android KitKat والإصدارات الأقدم + diff --git a/app/src/braveLegacy/res/values-az/strings.xml b/app/src/braveLegacy/res/values-az/strings.xml new file mode 100644 index 0000000000..ddcab332c7 --- /dev/null +++ b/app/src/braveLegacy/res/values-az/strings.xml @@ -0,0 +1,4 @@ + + + \'Yaddaş Giriş Çərçivəsi\' Android KitKat və ondan aşağı versiyalarda dəstəklənmir + diff --git a/app/src/braveLegacy/res/values-ca/strings.xml b/app/src/braveLegacy/res/values-ca/strings.xml new file mode 100644 index 0000000000..cc457ef1ae --- /dev/null +++ b/app/src/braveLegacy/res/values-ca/strings.xml @@ -0,0 +1,4 @@ + + + El \"Sistema d\'Accés a l\'Emmagatzematge\" no està implementat a Android KitKat i a versions anteriors + diff --git a/app/src/braveLegacy/res/values-ckb/strings.xml b/app/src/braveLegacy/res/values-ckb/strings.xml new file mode 100644 index 0000000000..2dc426a97c --- /dev/null +++ b/app/src/braveLegacy/res/values-ckb/strings.xml @@ -0,0 +1,4 @@ + + + \'Storage Access Framework\' پشتگیری نه‌كراوه‌ له‌سه‌ر وه‌شانه‌كانی ئه‌ندرۆید كیتكات و نزمتر + diff --git a/app/src/braveLegacy/res/values-cs/strings.xml b/app/src/braveLegacy/res/values-cs/strings.xml new file mode 100644 index 0000000000..861d1ebb6d --- /dev/null +++ b/app/src/braveLegacy/res/values-cs/strings.xml @@ -0,0 +1,4 @@ + + + \"Storage Access Framework\" není podporován na KitKat a níže + diff --git a/app/src/braveLegacy/res/values-de/strings.xml b/app/src/braveLegacy/res/values-de/strings.xml new file mode 100644 index 0000000000..3b4b840076 --- /dev/null +++ b/app/src/braveLegacy/res/values-de/strings.xml @@ -0,0 +1,4 @@ + + + Das „Storage Access Framework“ wird auf Android KitKat und niedriger nicht unterstützt + diff --git a/app/src/braveLegacy/res/values-el/strings.xml b/app/src/braveLegacy/res/values-el/strings.xml new file mode 100644 index 0000000000..17f62f109c --- /dev/null +++ b/app/src/braveLegacy/res/values-el/strings.xml @@ -0,0 +1,4 @@ + + + Το «Πλαίσιο Πρόσβασης Αποθήκευσης» δεν υποστηρίζεται σε Android KitKat και παλαιότερο + diff --git a/app/src/braveLegacy/res/values-es/strings.xml b/app/src/braveLegacy/res/values-es/strings.xml new file mode 100644 index 0000000000..04367a21f1 --- /dev/null +++ b/app/src/braveLegacy/res/values-es/strings.xml @@ -0,0 +1,4 @@ + + + El \'Sistema de Acceso al Almacenamiento\' no es sorportado en Android KitKat o versiones anteriores + diff --git a/app/src/braveLegacy/res/values-et/strings.xml b/app/src/braveLegacy/res/values-et/strings.xml new file mode 100644 index 0000000000..4546a0de60 --- /dev/null +++ b/app/src/braveLegacy/res/values-et/strings.xml @@ -0,0 +1,4 @@ + + + Android KitKat ja vanemad versioonid ei toeta salvestusjuurdepääsu raamistikku \'Storage Access Framework\' + diff --git a/app/src/braveLegacy/res/values-eu/strings.xml b/app/src/braveLegacy/res/values-eu/strings.xml new file mode 100644 index 0000000000..a2654043f6 --- /dev/null +++ b/app/src/braveLegacy/res/values-eu/strings.xml @@ -0,0 +1,4 @@ + + + \'Biltegiaren Sarrera Framework\'a ez da Android KitKat eta aurreko bertsioetan onartzen + diff --git a/app/src/braveLegacy/res/values-fa/strings.xml b/app/src/braveLegacy/res/values-fa/strings.xml new file mode 100644 index 0000000000..f6ab2dbf08 --- /dev/null +++ b/app/src/braveLegacy/res/values-fa/strings.xml @@ -0,0 +1,4 @@ + + + «چارچوب دسترسی ذخیره» روی اندروید کیت‌کت و پایین‌تر پشتیبانی نمی‌شود + diff --git a/app/src/braveLegacy/res/values-fi/strings.xml b/app/src/braveLegacy/res/values-fi/strings.xml new file mode 100644 index 0000000000..94e93f94db --- /dev/null +++ b/app/src/braveLegacy/res/values-fi/strings.xml @@ -0,0 +1,4 @@ + + + \'Storage Access Framework\' ei ole tuettu Android KitKatissa tai vanhemmissa versioissa + diff --git a/app/src/braveLegacy/res/values-fr/strings.xml b/app/src/braveLegacy/res/values-fr/strings.xml new file mode 100644 index 0000000000..44347a766d --- /dev/null +++ b/app/src/braveLegacy/res/values-fr/strings.xml @@ -0,0 +1,4 @@ + + + L’« Infrastructure d’accès au stockage » n’est pas prise en charge par Android KitKat et les versions antérieures + diff --git a/app/src/braveLegacy/res/values-gl/strings.xml b/app/src/braveLegacy/res/values-gl/strings.xml new file mode 100644 index 0000000000..fc21d28040 --- /dev/null +++ b/app/src/braveLegacy/res/values-gl/strings.xml @@ -0,0 +1,4 @@ + + + O \'Sistema de Acceso ao almacenamento\' non está soportado en Android KitKat e anteriores + diff --git a/app/src/braveLegacy/res/values-he/strings.xml b/app/src/braveLegacy/res/values-he/strings.xml new file mode 100644 index 0000000000..36623babf6 --- /dev/null +++ b/app/src/braveLegacy/res/values-he/strings.xml @@ -0,0 +1,4 @@ + + + ‚תשתית הגישה לאחסון’ אינה נתמכת על ידי Android KitKat ומטה + diff --git a/app/src/braveLegacy/res/values-hr/strings.xml b/app/src/braveLegacy/res/values-hr/strings.xml new file mode 100644 index 0000000000..26c44d5904 --- /dev/null +++ b/app/src/braveLegacy/res/values-hr/strings.xml @@ -0,0 +1,4 @@ + + + „Storage Access Framework“ nije podržan na Androidu KitKat i starijim + diff --git a/app/src/braveLegacy/res/values-hu/strings.xml b/app/src/braveLegacy/res/values-hu/strings.xml new file mode 100644 index 0000000000..77bfc11555 --- /dev/null +++ b/app/src/braveLegacy/res/values-hu/strings.xml @@ -0,0 +1,4 @@ + + + A „Storage Access Framework” nem támogatott Android KitKaten vagy régebbin + diff --git a/app/src/braveLegacy/res/values-in/strings.xml b/app/src/braveLegacy/res/values-in/strings.xml new file mode 100644 index 0000000000..8a3f826f4d --- /dev/null +++ b/app/src/braveLegacy/res/values-in/strings.xml @@ -0,0 +1,4 @@ + + + \'Storage Access Framework\' tidak didukung pada Android KitKat dan yang lebih rendah + diff --git a/app/src/braveLegacy/res/values-it/strings.xml b/app/src/braveLegacy/res/values-it/strings.xml new file mode 100644 index 0000000000..5e084e8030 --- /dev/null +++ b/app/src/braveLegacy/res/values-it/strings.xml @@ -0,0 +1,4 @@ + + + Il Framework di accesso all\'archiviazione non è supportato su Android KitKat e versioni precedenti + diff --git a/app/src/braveLegacy/res/values-ja/strings.xml b/app/src/braveLegacy/res/values-ja/strings.xml new file mode 100644 index 0000000000..cc627fb81d --- /dev/null +++ b/app/src/braveLegacy/res/values-ja/strings.xml @@ -0,0 +1,4 @@ + + + \'Storage Access Framework\' は Android KitKat 以下ではサポートされていません + diff --git a/app/src/braveLegacy/res/values-lt/strings.xml b/app/src/braveLegacy/res/values-lt/strings.xml new file mode 100644 index 0000000000..ea53b3431e --- /dev/null +++ b/app/src/braveLegacy/res/values-lt/strings.xml @@ -0,0 +1,4 @@ + + + \'Storage Access Framework\' nėra palaikomas Android KitKat ir žemesnėse versijose + diff --git a/app/src/braveLegacy/res/values-lv/strings.xml b/app/src/braveLegacy/res/values-lv/strings.xml new file mode 100644 index 0000000000..f6b7c7a882 --- /dev/null +++ b/app/src/braveLegacy/res/values-lv/strings.xml @@ -0,0 +1,4 @@ + + + “Krātuves Piekļuves Sistēma” ir neatbalstīta uz Android KitKat un zemākām versijām + diff --git a/app/src/braveLegacy/res/values-ml/strings.xml b/app/src/braveLegacy/res/values-ml/strings.xml new file mode 100644 index 0000000000..6d9e02be32 --- /dev/null +++ b/app/src/braveLegacy/res/values-ml/strings.xml @@ -0,0 +1,4 @@ + + + ആൻഡ്രോയ്ഡ് കിറ്റ് ക്യാറ്റോ അതിനു താഴെക്കോ ഉള്ളതിൽ \"സ്റ്റോറേജ് ആസസ്സ് ഫ്രെയിംവർക്ക് പിന്തുണക്കുന്നില്ല + diff --git a/app/src/braveLegacy/res/values-nb-rNO/strings.xml b/app/src/braveLegacy/res/values-nb-rNO/strings.xml new file mode 100644 index 0000000000..c7d1041953 --- /dev/null +++ b/app/src/braveLegacy/res/values-nb-rNO/strings.xml @@ -0,0 +1,4 @@ + + + «Lagringstilgangsrammeverket» støttes ikke på Android KitKat og tidligere. + diff --git a/app/src/braveLegacy/res/values-nl/strings.xml b/app/src/braveLegacy/res/values-nl/strings.xml new file mode 100644 index 0000000000..46f49d86a5 --- /dev/null +++ b/app/src/braveLegacy/res/values-nl/strings.xml @@ -0,0 +1,4 @@ + + + Het \'Storage Access Framework\' is niet ondersteund op Android KitKat en lager + diff --git a/app/src/braveLegacy/res/values-pa/strings.xml b/app/src/braveLegacy/res/values-pa/strings.xml new file mode 100644 index 0000000000..ff3e49b635 --- /dev/null +++ b/app/src/braveLegacy/res/values-pa/strings.xml @@ -0,0 +1,4 @@ + + + \'ਸਟੋਰੇਜ ਐਕਸੈੱਸ ਫ਼ਰੇਮਵਰਕ\' ਐਂਡਰਾਇਡ ਕਿਟਕੈਟ ਅਤੇ ਇਸਤੋਂ ਹੇਠਾਂ ਦੇ ਵਰਜਨਾਂ \'ਤੇ ਕੰਮ ਨਹੀਂ ਕਰਦਾ + diff --git a/app/src/braveLegacy/res/values-pl/strings.xml b/app/src/braveLegacy/res/values-pl/strings.xml new file mode 100644 index 0000000000..9f960b0f43 --- /dev/null +++ b/app/src/braveLegacy/res/values-pl/strings.xml @@ -0,0 +1,4 @@ + + + Systemowy selektor folderów (SAF) nie jest obsługiwany przez system Android KitKat i niższy + diff --git a/app/src/braveLegacy/res/values-pt-rBR/strings.xml b/app/src/braveLegacy/res/values-pt-rBR/strings.xml new file mode 100644 index 0000000000..708b3d35f0 --- /dev/null +++ b/app/src/braveLegacy/res/values-pt-rBR/strings.xml @@ -0,0 +1,4 @@ + + + O \'Storage Access Framework\' não é compatível com Android KitKat e versões anteriores + diff --git a/app/src/braveLegacy/res/values-pt-rPT/strings.xml b/app/src/braveLegacy/res/values-pt-rPT/strings.xml new file mode 100644 index 0000000000..6643c120e7 --- /dev/null +++ b/app/src/braveLegacy/res/values-pt-rPT/strings.xml @@ -0,0 +1,4 @@ + + + \'Storage Access Framework\' não é compatível com Android KitKat e versões anteriores + diff --git a/app/src/braveLegacy/res/values-pt/strings.xml b/app/src/braveLegacy/res/values-pt/strings.xml new file mode 100644 index 0000000000..82831008dc --- /dev/null +++ b/app/src/braveLegacy/res/values-pt/strings.xml @@ -0,0 +1,4 @@ + + + A \'Framework de acesso ao armazenamento\' não está disponível no Android KitKat e anteriores + diff --git a/app/src/braveLegacy/res/values-ro/strings.xml b/app/src/braveLegacy/res/values-ro/strings.xml new file mode 100644 index 0000000000..a0d109fc12 --- /dev/null +++ b/app/src/braveLegacy/res/values-ro/strings.xml @@ -0,0 +1,4 @@ + + + \"Storage Access Framework\" nu este acceptat pe Android KitKat și versiunile ulterioare + diff --git a/app/src/braveLegacy/res/values-ru/strings.xml b/app/src/braveLegacy/res/values-ru/strings.xml new file mode 100644 index 0000000000..68df13bf35 --- /dev/null +++ b/app/src/braveLegacy/res/values-ru/strings.xml @@ -0,0 +1,4 @@ + + + \"Storage Access Framework\" не поддерживается на Android KitKat и ниже + diff --git a/app/src/braveLegacy/res/values-sc/strings.xml b/app/src/braveLegacy/res/values-sc/strings.xml new file mode 100644 index 0000000000..48a1f40634 --- /dev/null +++ b/app/src/braveLegacy/res/values-sc/strings.xml @@ -0,0 +1,4 @@ + + + Su \'Storage Access Framework\' no est suportadu in Android KitKat e versiones prus betzas + diff --git a/app/src/braveLegacy/res/values-sk/strings.xml b/app/src/braveLegacy/res/values-sk/strings.xml new file mode 100644 index 0000000000..e8ec505382 --- /dev/null +++ b/app/src/braveLegacy/res/values-sk/strings.xml @@ -0,0 +1,4 @@ + + + \'Storage Access Framework\' nie je podporovaný v systéme Android KitKat a ani v starších verziách + diff --git a/app/src/braveLegacy/res/values-so/strings.xml b/app/src/braveLegacy/res/values-so/strings.xml new file mode 100644 index 0000000000..729578349c --- /dev/null +++ b/app/src/braveLegacy/res/values-so/strings.xml @@ -0,0 +1,4 @@ + + + \'SAF\' kuma shaqeeyo Android KitKat iyo wixii ka hooseeya + diff --git a/app/src/braveLegacy/res/values-sq/strings.xml b/app/src/braveLegacy/res/values-sq/strings.xml new file mode 100644 index 0000000000..3a27582237 --- /dev/null +++ b/app/src/braveLegacy/res/values-sq/strings.xml @@ -0,0 +1,4 @@ + + + \'Storage Access Framework\' nuk është e mbështetur në Android KitKat dhe më poshtë + diff --git a/app/src/braveLegacy/res/values-sr/strings.xml b/app/src/braveLegacy/res/values-sr/strings.xml new file mode 100644 index 0000000000..f08b92efce --- /dev/null +++ b/app/src/braveLegacy/res/values-sr/strings.xml @@ -0,0 +1,4 @@ + + + „Storage Access Framework“ није подржан на Андроиду 4.4 и старијим + diff --git a/app/src/braveLegacy/res/values-sv/strings.xml b/app/src/braveLegacy/res/values-sv/strings.xml new file mode 100644 index 0000000000..32ef29f3a6 --- /dev/null +++ b/app/src/braveLegacy/res/values-sv/strings.xml @@ -0,0 +1,4 @@ + + + \"Storage Access Framework\" är inte tillgängligt på Android KitKat och tidigare versioner + diff --git a/app/src/braveLegacy/res/values-tr/strings.xml b/app/src/braveLegacy/res/values-tr/strings.xml new file mode 100644 index 0000000000..668050ca42 --- /dev/null +++ b/app/src/braveLegacy/res/values-tr/strings.xml @@ -0,0 +1,4 @@ + + + \'Depolama Erişimi Çerçevesi\' Android KitKat ve altında desteklenmez + diff --git a/app/src/braveLegacy/res/values-uk/strings.xml b/app/src/braveLegacy/res/values-uk/strings.xml new file mode 100644 index 0000000000..47b7d0ed5e --- /dev/null +++ b/app/src/braveLegacy/res/values-uk/strings.xml @@ -0,0 +1,4 @@ + + + «Фреймворк доступу до сховища» (SAF) не підтримується в KitKat і нижче + diff --git a/app/src/braveLegacy/res/values-vi/strings.xml b/app/src/braveLegacy/res/values-vi/strings.xml new file mode 100644 index 0000000000..cc1d67e4b8 --- /dev/null +++ b/app/src/braveLegacy/res/values-vi/strings.xml @@ -0,0 +1,4 @@ + + + \'Storage Access Framework\' không được hỗ trợ trên Android KitKat và cũ hơn + diff --git a/app/src/braveLegacy/res/values-zh-rCN/strings.xml b/app/src/braveLegacy/res/values-zh-rCN/strings.xml new file mode 100644 index 0000000000..9161f80ff6 --- /dev/null +++ b/app/src/braveLegacy/res/values-zh-rCN/strings.xml @@ -0,0 +1,4 @@ + + + Android KitKat 及更低版本不支持“存储访问框架” + diff --git a/app/src/braveLegacy/res/values-zh-rHK/strings.xml b/app/src/braveLegacy/res/values-zh-rHK/strings.xml new file mode 100644 index 0000000000..f57b24f76f --- /dev/null +++ b/app/src/braveLegacy/res/values-zh-rHK/strings.xml @@ -0,0 +1,4 @@ + + + Android KitKat 以及樓下唔支援「儲存空間存取框架」 + diff --git a/app/src/braveLegacy/res/values-zh-rTW/strings.xml b/app/src/braveLegacy/res/values-zh-rTW/strings.xml new file mode 100644 index 0000000000..070b32ad19 --- /dev/null +++ b/app/src/braveLegacy/res/values-zh-rTW/strings.xml @@ -0,0 +1,4 @@ + + + Android KitKat 或更舊的版本不支援「儲存空間存取框架」 + diff --git a/app/src/braveLegacy/res/values/settings_keys.xml b/app/src/braveLegacy/res/values/settings_keys.xml new file mode 100644 index 0000000000..9cbf9f3bfc --- /dev/null +++ b/app/src/braveLegacy/res/values/settings_keys.xml @@ -0,0 +1,19 @@ + + + @string/search_filter_ui_chip_dialog_key + + + + @string/search_filter_ui_option_menu_style_key + + @string/search_filter_ui_chip_dialog_key + + + + + @string/search_filter_ui_style + + @string/search_filter_ui_chip_dialog + + + diff --git a/app/src/braveLegacy/res/values/strings.xml b/app/src/braveLegacy/res/values/strings.xml new file mode 100644 index 0000000000..5b4aa1de46 --- /dev/null +++ b/app/src/braveLegacy/res/values/strings.xml @@ -0,0 +1,4 @@ + + + The \'Storage Access Framework\' is not supported on Android KitKat and below + diff --git a/app/src/debug/java/org/schabi/newpipe/DebugApp.kt b/app/src/debug/java/org/schabi/newpipe/DebugApp.kt index 70b9ec2807..e693548057 100644 --- a/app/src/debug/java/org/schabi/newpipe/DebugApp.kt +++ b/app/src/debug/java/org/schabi/newpipe/DebugApp.kt @@ -4,7 +4,6 @@ import androidx.preference.PreferenceManager import com.facebook.stetho.Stetho import com.facebook.stetho.okhttp3.StethoInterceptor import leakcanary.LeakCanary -import okhttp3.OkHttpClient import org.schabi.newpipe.extractor.downloader.Downloader class DebugApp : App() { @@ -24,8 +23,8 @@ class DebugApp : App() { } override fun getDownloader(): Downloader { - val downloader = DownloaderImpl.init( - OkHttpClient.Builder() + val downloader = DownloaderImpl.getInstance().init( + DownloaderImpl.getInstance().newBuilder .addNetworkInterceptor(StethoInterceptor()) ) setCookiesToDownloader(downloader) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f86e5b5c0e..c392c8c557 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -71,12 +71,10 @@ android:exported="false" android:label="@string/title_activity_play_queue" android:launchMode="singleTask" /> - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -407,6 +444,14 @@ + + + @@ -425,4 +470,4 @@ android:name="com.samsung.android.multidisplay.keep_process_alive" android:value="true" /> - + \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/App.java b/app/src/main/java/org/schabi/newpipe/App.java index d92425d200..295c47bf07 100644 --- a/app/src/main/java/org/schabi/newpipe/App.java +++ b/app/src/main/java/org/schabi/newpipe/App.java @@ -1,6 +1,5 @@ package org.schabi.newpipe; -import android.app.Application; import android.content.Context; import android.content.SharedPreferences; import android.util.Log; @@ -57,7 +56,7 @@ * along with NewPipe. If not, see . */ -public class App extends Application { +public class App extends BraveApp { public static final String PACKAGE_NAME = BuildConfig.APPLICATION_ID; private static final String TAG = App.class.toString(); @@ -116,16 +115,18 @@ public void onCreate() { && prefs.getBoolean(getString(R.string.show_image_indicators_key), false)); configureRxJavaErrorHandler(); + BraveDownloaderImplUtils.CONFIG.registerOnChanged(getApplicationContext()); } @Override public void onTerminate() { super.onTerminate(); PicassoHelper.terminate(); + BraveDownloaderImplUtils.CONFIG.unRegisterOnChanged(getApplicationContext()); } protected Downloader getDownloader() { - final DownloaderImpl downloader = DownloaderImpl.init(null); + final DownloaderImpl downloader = DownloaderImpl.getInstance().init(null); setCookiesToDownloader(downloader); return downloader; } diff --git a/app/src/main/java/org/schabi/newpipe/BraveDownloaderImplUtils.java b/app/src/main/java/org/schabi/newpipe/BraveDownloaderImplUtils.java new file mode 100644 index 0000000000..c77d77b799 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/BraveDownloaderImplUtils.java @@ -0,0 +1,156 @@ +package org.schabi.newpipe; + +import android.content.Context; +import android.content.SharedPreferences; + +import org.schabi.newpipe.extractor.downloader.BraveCookieManager; +import org.schabi.newpipe.util.image.PicassoHelper; + +import java.io.IOException; +import java.net.CookiePolicy; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import androidx.annotation.NonNull; +import androidx.preference.PreferenceManager; +import okhttp3.Interceptor; +import okhttp3.JavaNetCookieJar; +import okhttp3.OkHttpClient; + +import static org.schabi.newpipe.DownloaderImpl.USER_AGENT; + +/** + * Used for code that only exists in BraveNewPipe and is used + * within the {@link DownloaderImpl}. + */ +public final class BraveDownloaderImplUtils { + public static final Config CONFIG = new Config(); + + private BraveDownloaderImplUtils() { + } + + // some servers eg rumble do not allow HEAD requests anymore (discovered 202300203) + public static long getContentLengthViaGet(final String url) throws IOException { + final HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); + conn.setInstanceFollowRedirects(true); + conn.setRequestProperty("User-Agent", USER_AGENT); + conn.setRequestProperty("Accept", "*/*"); + conn.setRequestProperty("Accept-Encoding", "*"); + final String contentSize = conn.getHeaderField("Content-Length"); + conn.disconnect(); + return Long.parseLong(contentSize); + } + + public static void addOrRemoveInterceptors(final OkHttpClient.Builder builder) { + final Context context = App.getApp().getApplicationContext(); + final SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(context); + + addOrRemoveHostInterceptor(builder, context, settings); + addOrRemoveTimeoutInterceptor(builder, context, settings); + } + + public static void addOrRemoveHostInterceptor( + final OkHttpClient.Builder builder, + final Context context, + final SharedPreferences settings) { + + final Set selectedHosts = settings.getStringSet( + context.getString(R.string.brave_settings_host_replace_key), + Collections.emptySet()); + + final Optional hostInterceptor = BraveHostInterceptor.getInterceptor(builder); + if (selectedHosts.isEmpty()) { + hostInterceptor.ifPresent(interceptor -> builder.interceptors().remove(interceptor)); + } else { + final Map replaceHosts = new HashMap<>(); + for (final String oldAndNewHost : selectedHosts) { + final String[] result = oldAndNewHost.split(":"); + replaceHosts.put(result[0], result[1]); + } + + if (hostInterceptor.isPresent()) { + ((BraveHostInterceptor) hostInterceptor.get()).setHosts(replaceHosts); + } else { + builder.addInterceptor(new BraveHostInterceptor(replaceHosts)); + } + } + } + + private static void addOrRemoveTimeoutInterceptor( + final OkHttpClient.Builder builder, + final Context context, + final SharedPreferences settings) { + + final boolean isClientForSponsorblockingOrReturnDislikesEnabled = (settings.getBoolean( + context.getString(R.string.sponsor_block_enable_key), false) + || settings.getBoolean( + context.getString(R.string.enable_return_youtube_dislike_key), false)); + + final Optional timeoutInterceptor = + BraveTimeoutInterceptor.getInterceptor(builder); + if (isClientForSponsorblockingOrReturnDislikesEnabled) { + if (timeoutInterceptor.isEmpty()) { + builder.addInterceptor(new BraveTimeoutInterceptor()); + } + } else { + timeoutInterceptor.ifPresent(interceptor -> builder.interceptors().remove(interceptor)); + } + } + + /** + * Rumble needs to handle cookies to correctly redirect. + * + * It was reported in + * issue#123 + * even though it seems it was only temporary Rumble glitch this functionality is added here. + * + * @param theBuilder the builder + */ + public static void addCookieManager(final OkHttpClient.Builder theBuilder) { + final BraveCookieManager cookieManager = new BraveCookieManager(); + cookieManager.setCookiePolicy(CookiePolicy.ACCEPT_ALL); + theBuilder.cookieJar(new JavaNetCookieJar(cookieManager)); + } + + /** + * Listen to the SharedPreferences and handle if replacing hosts or sponsorblock are enabled. + */ + public static class Config implements SharedPreferences.OnSharedPreferenceChangeListener { + + public void registerOnChanged(@NonNull final Context context) { + PreferenceManager.getDefaultSharedPreferences(context) + .registerOnSharedPreferenceChangeListener(this); + } + + @Override + public void onSharedPreferenceChanged( + final SharedPreferences settings, final String configOption) { + + final Context context = App.getApp().getApplicationContext(); + if (configOption.equals( + context.getString(R.string.brave_settings_host_replace_key)) + || configOption.equals( + context.getString(R.string.sponsor_block_enable_key)) + || configOption.equals( + context.getString(R.string.enable_return_youtube_dislike_key))) { + + DownloaderImpl.getInstance().reInitInterceptors(); + } + + if (configOption.equals( + context.getString(R.string.brave_settings_host_replace_key))) { + PicassoHelper.reInit(context); + } + } + + public void unRegisterOnChanged(@NonNull final Context context) { + PreferenceManager.getDefaultSharedPreferences(context) + .registerOnSharedPreferenceChangeListener(this); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/BraveHostInterceptor.java b/app/src/main/java/org/schabi/newpipe/BraveHostInterceptor.java new file mode 100644 index 0000000000..b01c3caf6c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/BraveHostInterceptor.java @@ -0,0 +1,50 @@ +package org.schabi.newpipe; + +import java.io.IOException; +import java.util.Map; +import java.util.Optional; + +import okhttp3.HttpUrl; +import okhttp3.Interceptor; +import okhttp3.OkHttpClient; +import okhttp3.Request; + +/** + * This interceptor allows to replace a hostname with another on the fly. + * Useful to get around stupid censorship. + */ +public class BraveHostInterceptor implements Interceptor { + private Map replaceHosts; + + public BraveHostInterceptor(final Map hosts) { + setHosts(hosts); + } + + public static Optional getInterceptor( + final OkHttpClient.Builder builder) { + return builder.interceptors().stream().filter( + BraveHostInterceptor.class::isInstance).findFirst(); + } + + public void setHosts(final Map hosts) { + this.replaceHosts = hosts; + } + + @Override + public okhttp3.Response intercept(final Chain chain) throws IOException { + final Request request = chain.request(); + final String newHostName = replaceHosts.get(request.url().host()); + + if (newHostName != null) { + final HttpUrl newUrl = request.url().newBuilder() + .host(newHostName) + .build(); + final Request newRequest = request.newBuilder() + .url(newUrl) + .build(); + return chain.proceed(newRequest); + } + + return chain.proceed(request); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/BraveNewVersionWorkerHelper.kt b/app/src/main/java/org/schabi/newpipe/BraveNewVersionWorkerHelper.kt new file mode 100644 index 0000000000..88c315d934 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/BraveNewVersionWorkerHelper.kt @@ -0,0 +1,16 @@ +package org.schabi.newpipe + +import com.grack.nanojson.JsonObject +import com.grack.nanojson.JsonParser +import org.schabi.newpipe.extractor.downloader.Response + +object BraveNewVersionWorkerHelper { + + fun getVersionInfo(response: Response): JsonObject { + val newpipeVersionInfo = JsonParser.`object`() + .from(response.responseBody()).getObject("flavors") + .getObject("github").getObject("stable") + return newpipeVersionInfo + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/BraveTag.java b/app/src/main/java/org/schabi/newpipe/BraveTag.java new file mode 100644 index 0000000000..593781cc04 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/BraveTag.java @@ -0,0 +1,22 @@ +package org.schabi.newpipe; + +public final class BraveTag { + + /** + * This just truncate the string to have 23 chars. + *

+ * This has to be <= 23 chars on devices running Android 7 or lower (API <= 25) + * or it fails with an IllegalArgumentException + * https://stackoverflow.com/a/54744028 + * + * @param longTag the tag you want to shorten + * @return the 23 chars tag string + */ + public String tagShort23(final String longTag) { + if (longTag.length() > 23) { + return longTag.substring(0, 22); + } else { + return longTag; + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/BraveTimeoutInterceptor.java b/app/src/main/java/org/schabi/newpipe/BraveTimeoutInterceptor.java new file mode 100644 index 0000000000..1bd7e1c101 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/BraveTimeoutInterceptor.java @@ -0,0 +1,79 @@ +package org.schabi.newpipe; + +import org.schabi.newpipe.extractor.downloader.Response; +import org.schabi.newpipe.extractor.exceptions.ReCaptchaException; + +import java.io.IOException; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +import okhttp3.Interceptor; +import okhttp3.OkHttpClient; +import okhttp3.Request; + +/** + * Interceptor that changes the read timeout for the request. + *

+ * The value is read from a header which will be removed afterwards. + */ +public class BraveTimeoutInterceptor implements Interceptor { + + /** + * Custom timeout header (will be removed before sending). + */ + private static final String CUSTOM_TIMEOUT = "custom-timeout"; + + /** + * Get Wrapper around DownloaderImpl.get() that sets custom timeout via header. + *

+ * The timeout is done via a extra http header and read in an + * {@link BraveTimeoutInterceptor} and + * + * @param url tha url you want to call + * @param timeout the timeout you want to have for this request + * @return the response + * @throws IOException + * @throws ReCaptchaException + */ + public static Response get( + final String url, + final int timeout) + throws IOException, ReCaptchaException { + final Map> headers = new HashMap<>(); + headers.put(CUSTOM_TIMEOUT, Collections.singletonList(String.valueOf(timeout))); + return DownloaderImpl.getInstance().get(url, headers); + } + + public static Optional getInterceptor( + final OkHttpClient.Builder builder) { + return builder.interceptors().stream().filter( + BraveTimeoutInterceptor.class::isInstance).findFirst(); + } + + @Override + public okhttp3.Response intercept(final Chain chain) throws IOException { + + final Request request = chain.request(); + final String timeoutForThisRequest = request.header(CUSTOM_TIMEOUT); + + if (timeoutForThisRequest != null) { + final int timeout = Integer.parseInt(timeoutForThisRequest); + + final Chain newChain = chain.withReadTimeout(timeout, TimeUnit.SECONDS) + .withConnectTimeout(timeout, TimeUnit.SECONDS) + .withWriteTimeout(timeout, TimeUnit.SECONDS); + + final Request requestWithTimeoutHeaderRemoved = newChain.request().newBuilder() + .removeHeader(CUSTOM_TIMEOUT) + .build(); + + return newChain.proceed(requestWithTimeoutHeaderRemoved); + } + + return chain.proceed(request); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/DownloaderImpl.java b/app/src/main/java/org/schabi/newpipe/DownloaderImpl.java index 9ddbe96dfc..946d0494da 100644 --- a/app/src/main/java/org/schabi/newpipe/DownloaderImpl.java +++ b/app/src/main/java/org/schabi/newpipe/DownloaderImpl.java @@ -35,33 +35,49 @@ public final class DownloaderImpl extends Downloader { public static final String YOUTUBE_RESTRICTED_MODE_COOKIE = "PREF=f2=8000000"; public static final String YOUTUBE_DOMAIN = "youtube.com"; - private static DownloaderImpl instance; + private static final DownloaderImpl INSTANCE = new DownloaderImpl(); private final Map mCookies; - private final OkHttpClient client; + private OkHttpClient client = new OkHttpClient(); - private DownloaderImpl(final OkHttpClient.Builder builder) { - this.client = builder - .readTimeout(30, TimeUnit.SECONDS) + private DownloaderImpl() { + this.mCookies = new HashMap<>(); + } + + private void initInternal(final @Nullable OkHttpClient.Builder builder) { + final OkHttpClient.Builder theBuilder = + builder != null ? builder : client.newBuilder(); + theBuilder.readTimeout(30, TimeUnit.SECONDS); // .cache(new Cache(new File(context.getExternalCacheDir(), "okhttp"), // 16 * 1024 * 1024)) - .build(); - this.mCookies = new HashMap<>(); + BraveDownloaderImplUtils.addOrRemoveInterceptors(theBuilder); + BraveDownloaderImplUtils.addCookieManager(theBuilder); + this.client = theBuilder.build(); + } + + public void reInitInterceptors() { + final OkHttpClient.Builder builder = client.newBuilder(); + BraveDownloaderImplUtils.addOrRemoveInterceptors(builder); + this.client = builder.build(); } /** * It's recommended to call exactly once in the entire lifetime of the application. * - * @param builder if null, default builder will be used + * @param builder if null, default builder will be used. If supplying a builder always use + * {@link #getNewBuilder()} to retrieve one - unless you know what you are doing. * @return a new instance of {@link DownloaderImpl} */ - public static DownloaderImpl init(@Nullable final OkHttpClient.Builder builder) { - instance = new DownloaderImpl( - builder != null ? builder : new OkHttpClient.Builder()); - return instance; + public DownloaderImpl init(@Nullable final OkHttpClient.Builder builder) { + initInternal(builder); + return INSTANCE; } public static DownloaderImpl getInstance() { - return instance; + return INSTANCE; + } + + public OkHttpClient.Builder getNewBuilder() { + return client.newBuilder(); } public String getCookies(final String url) { @@ -115,7 +131,11 @@ public void updateYoutubeRestrictedModeCookies(final boolean youtubeRestrictedMo public long getContentLength(final String url) throws IOException { try { final Response response = head(url); - return Long.parseLong(response.getHeader("Content-Length")); + if (response.responseCode() == 405) { // HEAD Method not allowed + return BraveDownloaderImplUtils.getContentLengthViaGet(url); + } else { + return Long.parseLong(response.getHeader("Content-Length")); + } } catch (final NumberFormatException e) { throw new IOException("Invalid content length", e); } catch (final ReCaptchaException e) { @@ -176,7 +196,17 @@ public Response execute(@NonNull final Request request) } final String latestUrl = response.request().url().toString(); - return new Response(response.code(), response.message(), response.headers().toMultimap(), - responseBodyToReturn, latestUrl); + final Response downloaderResponse = new Response( + response.code(), + response.message(), + response.headers().toMultimap(), + responseBodyToReturn, + latestUrl + ); + + // always close the OkHttp Response + response.close(); + + return downloaderResponse; } } diff --git a/app/src/main/java/org/schabi/newpipe/LocalPlayerActivity.java b/app/src/main/java/org/schabi/newpipe/LocalPlayerActivity.java new file mode 100644 index 0000000000..dc7cc7e775 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/LocalPlayerActivity.java @@ -0,0 +1,210 @@ +package org.schabi.newpipe; + +import android.content.Intent; +import android.content.res.Configuration; +import android.graphics.Color; +import android.os.Build; +import android.os.Bundle; +import android.util.DisplayMetrics; +import android.util.Log; +import android.view.View; +import android.view.WindowManager; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; + +import com.google.android.exoplayer2.PlaybackParameters; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.ui.PlayerView; +import com.grack.nanojson.JsonObject; +import com.grack.nanojson.JsonParser; + +import org.schabi.newpipe.player.LocalPlayer; +import org.schabi.newpipe.player.LocalPlayerListener; +import org.schabi.newpipe.player.helper.PlaybackParameterDialog; +import org.schabi.newpipe.util.ThemeHelper; +import org.schabi.newpipe.util.VideoSegment; + +import java.util.ArrayList; +import java.util.List; + +public class LocalPlayerActivity extends AppCompatActivity implements Player.Listener, + LocalPlayerListener, PlaybackParameterDialog.Callback { + private LocalPlayer localPlayer; + private PlayerView playerView; + public static final String TAG = "LocalPlayerActivity"; + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_local_player); + ThemeHelper.setTheme(this); + + hideSystemUi(isLandscape()); + + final Intent intent = getIntent(); + + final String uri = parseUriFromIntent(intent); + final VideoSegment[] segments = parseSegmentsFromIntent(intent); + + localPlayer = new LocalPlayer(this); + localPlayer.initialize(uri, segments); + localPlayer.setListener(this); + + playerView = findViewById(R.id.player_view); + playerView.setPlayer(localPlayer.getExoPlayer()); + + playerView.getVideoSurfaceView().setOnLongClickListener(v -> { + showPlaybackParameterDialog(); + return false; + }); + + playerView.getVideoSurfaceView().setOnClickListener(v -> playerView.performClick()); + } + + public void showPlaybackParameterDialog() { + final PlaybackParameters playbackParameters = + localPlayer.getExoPlayer().getPlaybackParameters(); + + boolean skipSilence = false; + + if (localPlayer.getExoPlayer().getAudioComponent() != null) { + skipSilence = localPlayer.getExoPlayer().getAudioComponent().getSkipSilenceEnabled(); + } + PlaybackParameterDialog.newInstance(playbackParameters.speed, playbackParameters.pitch, + skipSilence, this) + .show(getSupportFragmentManager(), TAG); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + localPlayer.destroy(); + } + + @Override + public void onConfigurationChanged(@NonNull final Configuration newConfig) { + super.onConfigurationChanged(newConfig); + + hideSystemUi(isLandscape()); + } + + @Override + public void onPlaybackParameterChanged(final float playbackTempo, final float playbackPitch, + final boolean playbackSkipSilence) { + localPlayer.setPlaybackParameters(playbackTempo, playbackPitch, playbackSkipSilence); + } + + @Override + public void onBlocked(final SimpleExoPlayer player) { + + } + + @Override + public void onPlaying(final SimpleExoPlayer player) { + setKeepScreenOn(true); + } + + @Override + public void onBuffering(final SimpleExoPlayer player) { + setKeepScreenOn(true); + } + + @Override + public void onPaused(final SimpleExoPlayer player) { + setKeepScreenOn(false); + } + + @Override + public void onPausedSeek(final SimpleExoPlayer player) { + + } + + @Override + public void onCompleted(final SimpleExoPlayer player) { + setKeepScreenOn(false); + } + + private static String parseUriFromIntent(final Intent intent) { + return intent.getDataString(); + } + + private static VideoSegment[] parseSegmentsFromIntent(final Intent intent) { + final List result = new ArrayList<>(); + + final String segmentsJson = intent.getStringExtra("segments"); + + if (segmentsJson != null && segmentsJson.length() > 0) { + try { + final JsonObject obj = JsonParser.object().from(segmentsJson); + + for (final Object item : obj.getArray("segments")) { + final JsonObject itemObject = (JsonObject) item; + + final double startTime = itemObject.getDouble("start"); + final double endTime = itemObject.getDouble("end"); + final String category = itemObject.getString("category"); + + final VideoSegment segment = new VideoSegment(startTime, endTime, category); + result.add(segment); + } + } catch (final Exception e) { + Log.e(TAG, "Error initializing segments", e); + } + } + + return result.toArray(new VideoSegment[0]); + } + + private void setKeepScreenOn(final boolean keepScreenOn) { + if (keepScreenOn) { + getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } else { + getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } + playerView.getRootView().setKeepScreenOn(keepScreenOn); + } + + private void hideSystemUi(final boolean isLandscape) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + getWindow().getAttributes().layoutInDisplayCutoutMode = + WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES; + } + + int visibility; + + if (isLandscape) { + visibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE + | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY; + } else { + visibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE + | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY; + } + + if (!isInMultiWindow()) { + visibility |= View.SYSTEM_UI_FLAG_FULLSCREEN; + } + + getWindow().getDecorView().setSystemUiVisibility(visibility); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && (isInMultiWindow())) { + getWindow().setStatusBarColor(Color.TRANSPARENT); + getWindow().setNavigationBarColor(Color.TRANSPARENT); + } + getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + } + + private boolean isInMultiWindow() { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isInMultiWindowMode(); + } + + boolean isLandscape() { + final DisplayMetrics metrics = getResources().getDisplayMetrics(); + return metrics.heightPixels < metrics.widthPixels; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/NewVersionWorker.kt b/app/src/main/java/org/schabi/newpipe/NewVersionWorker.kt index 806767316d..904554b6e7 100644 --- a/app/src/main/java/org/schabi/newpipe/NewVersionWorker.kt +++ b/app/src/main/java/org/schabi/newpipe/NewVersionWorker.kt @@ -16,7 +16,6 @@ import androidx.work.WorkManager import androidx.work.Worker import androidx.work.WorkerParameters import androidx.work.workDataOf -import com.grack.nanojson.JsonParser import com.grack.nanojson.JsonParserException import org.schabi.newpipe.extractor.downloader.Response import org.schabi.newpipe.extractor.exceptions.ReCaptchaException @@ -116,6 +115,13 @@ class NewVersionWorker( .getObject(0) .getString("browser_download_url") compareAppVersionAndShowNotification(versionName, apkLocationUrl) + // BRAVE NEWPIPE CONFLICT + // val newpipeVersionInfo = BraveNewVersionWorkerHelper.getVersionInfo(response) +// + // val versionName = newpipeVersionInfo.getString("version") + // val versionCode = newpipeVersionInfo.getInt("version_code") + // val apkLocationUrl = newpipeVersionInfo.getString("apk") + // compareAppVersionAndShowNotification(versionName, apkLocationUrl, versionCode) } catch (e: JsonParserException) { if (DEBUG) { Log.w(TAG, "Invalid json", e) @@ -140,6 +146,9 @@ class NewVersionWorker( private val DEBUG = MainActivity.DEBUG private val TAG = NewVersionWorker::class.java.simpleName private const val NEWPIPE_API_URL = "https://api.github.com/repos/MaintainTeam/LastPipeBender/releases/latest" + // BRAVE NEWPIPE CONFLICT + // private const val NEWPIPE_API_URL = + // "https://raw.githubusercontent.com/bravenewpipe/bnp-r-mgr/master/api/data.json" private const val IS_MANUAL = "isManual" @JvmStatic diff --git a/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt b/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt index 7f148e9b5c..d10db0d0a9 100644 --- a/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt +++ b/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt @@ -71,6 +71,9 @@ class AboutActivity : AppCompatActivity() { ): View { FragmentAboutBinding.inflate(inflater, container, false).apply { aboutAppVersion.text = BuildConfig.VERSION_NAME + braveMore.braveAppSignature.text = BuildConfig.APPLICATION_ID + braveMore.aboutAppFlavor.text = BuildConfig.FLAVOR + braveAbout.braveAboutGithubLink.openLink(R.string.brave_github_url) aboutGithubLink.openLink(R.string.github_url) aboutDonationLink.openLink(R.string.donation_url) aboutWebsiteLink.openLink(R.string.website_url) diff --git a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java index db2066b278..605820d34b 100644 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java @@ -1,6 +1,7 @@ package org.schabi.newpipe.download; import static org.schabi.newpipe.extractor.stream.DeliveryMethod.PROGRESSIVE_HTTP; +import static org.schabi.newpipe.ktx.ViewUtils.animate; import static org.schabi.newpipe.util.ListHelper.getStreamsOfSpecifiedDelivery; import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; @@ -65,11 +66,13 @@ import org.schabi.newpipe.util.PermissionHelper; import org.schabi.newpipe.util.SecondaryStreamHelper; import org.schabi.newpipe.util.SimpleOnSeekBarChangeListener; +import org.schabi.newpipe.util.SponsorBlockUtils; import org.schabi.newpipe.util.StreamItemAdapter; import org.schabi.newpipe.util.StreamItemAdapter.StreamInfoWrapper; import org.schabi.newpipe.util.AudioTrackAdapter; import org.schabi.newpipe.util.AudioTrackAdapter.AudioTracksWrapper; import org.schabi.newpipe.util.ThemeHelper; +import org.schabi.newpipe.util.VideoSegment; import java.io.File; import java.io.IOException; @@ -81,7 +84,11 @@ import icepick.Icepick; 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.disposables.Disposable; +import io.reactivex.rxjava3.schedulers.Schedulers; import us.shandian.giga.get.MissionRecoveryInfo; import us.shandian.giga.postprocessing.Postprocessing; import us.shandian.giga.service.DownloadManager; @@ -129,6 +136,8 @@ public class DownloadDialog extends DialogFragment private SharedPreferences prefs; + private VideoSegment[] segments; + // Variables for file name and MIME type when picking new folder because it's not set yet private String filenameTmp; private String mimeTmp; @@ -142,6 +151,8 @@ public class DownloadDialog extends DialogFragment private final ActivityResultLauncher requestDownloadPickVideoFolderLauncher = registerForActivityResult( new StartActivityForResult(), this::requestDownloadPickVideoFolderResult); + @NonNull + private Disposable youtubeVideoSegmentsDisposable; /*////////////////////////////////////////////////////////////////////////// // Instance creation @@ -190,6 +201,10 @@ public DownloadDialog(@NonNull final Context context, @NonNull final StreamInfo this.selectedVideoIndex = ListHelper.getDefaultResolutionIndex(context, videoStreams); } + public void setVideoSegments(final VideoSegment[] seg) { + this.segments = seg; + } + /*////////////////////////////////////////////////////////////////////////// // Android lifecycle @@ -233,8 +248,6 @@ public void onServiceConnected(final ComponentName cname, final IBinder service) downloadManager = mgr.getDownloadManager(); askForSavePath = mgr.askForSavePath(); - okButton.setEnabled(true); - context.unbindService(this); } @@ -311,7 +324,10 @@ public void onViewCreated(@NonNull final View view, dialogBinding.audioTrackSpinner.setOnItemSelectedListener(this); dialogBinding.videoAudioGroup.setOnCheckedChangeListener(this); + showLoading(); + initToolbar(dialogBinding.toolbarLayout.toolbar); + checkForYoutubeVideoSegments(); setupDownloadOptions(); prefs = PreferenceManager.getDefaultSharedPreferences(requireContext()); @@ -365,6 +381,7 @@ public void onDestroy() { @Override public void onDestroyView() { + youtubeVideoSegmentsDisposable.dispose(); dialogBinding = null; super.onDestroyView(); } @@ -1138,11 +1155,45 @@ private void continueSelectedDownload(@NonNull final StoredFileHelper storage) { } DownloadManagerService.startMission(context, urls, storage, kind, threads, - currentInfo.getUrl(), psName, psArgs, nearLength, new ArrayList<>(recoveryInfo)); + currentInfo.getUrl(), psName, psArgs, nearLength, new ArrayList<>(recoveryInfo), + segments); Toast.makeText(context, getString(R.string.download_has_started), Toast.LENGTH_SHORT).show(); dismiss(); } + + private void checkForYoutubeVideoSegments() { + youtubeVideoSegmentsDisposable = Single.fromCallable(() -> { + VideoSegment[] videoSegments = null; + try { + videoSegments = SponsorBlockUtils + .getYouTubeVideoSegments(getContext(), currentInfo); + } catch (final Exception e) { + // TODO: handle? + } + + return videoSegments == null + ? new VideoSegment[0] + : videoSegments; + }) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(videoSegments -> { + setVideoSegments(videoSegments); + okButton.setEnabled(true); + hideLoading(); + }); + } + + public void showLoading() { + dialogBinding.fileName.setVisibility(View.GONE); + animate(dialogBinding.loadingProgressBar, true, 400); + } + + public void hideLoading() { + animate(dialogBinding.loadingProgressBar, false, 0); + dialogBinding.fileName.setVisibility(View.VISIBLE); + } } diff --git a/app/src/main/java/org/schabi/newpipe/error/BraveErrorActivityHelper.java b/app/src/main/java/org/schabi/newpipe/error/BraveErrorActivityHelper.java new file mode 100644 index 0000000000..c537f4fb05 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/error/BraveErrorActivityHelper.java @@ -0,0 +1,33 @@ +package org.schabi.newpipe.error; + +import java.util.ArrayList; +import java.util.List; + +public final class BraveErrorActivityHelper { + + private BraveErrorActivityHelper() { + } + + /** + * Skip some traces as we might get TransactionTooLargeException exception. + * + * @param stackTraces the full stack traces + * @return the truncated traces list that will not crash the Binder or whatever. + */ + public static List truncateAsNeeded(final String[] stackTraces) { + final int limit = 104857; // limit to around 100k + + int size = 0; + final List finalList = new ArrayList<>(); + + for (final String trace : stackTraces) { + if (limit < size) { + finalList.add("BraveNewPipe TRUNCATED trace"); + break; + } + size += trace.length(); + finalList.add(trace); + } + return finalList; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/error/BraveErrorInfoTracesParceler.kt b/app/src/main/java/org/schabi/newpipe/error/BraveErrorInfoTracesParceler.kt new file mode 100644 index 0000000000..6d00f29041 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/error/BraveErrorInfoTracesParceler.kt @@ -0,0 +1,44 @@ +package org.schabi.newpipe.error + +import android.os.Parcel +import kotlinx.parcelize.Parceler +import java.io.ByteArrayOutputStream +import java.nio.charset.StandardCharsets +import java.util.zip.GZIPInputStream +import java.util.zip.GZIPOutputStream + +/** + * The binder can not handle to much data and throws TransactionTooLargeException. + * + * This Parceler tries to skip this fact with temporary gzip the data. Seems to + * work -- but sending over eMail still needs some truncating + * see {@link BraveErrorActivityHelper}. + */ +object BraveErrorInfoTracesParceler : Parceler> { + override fun create(parcel: Parcel): Array { + val byteArray = ByteArray(parcel.readInt()) + parcel.readByteArray(byteArray) + val unzipped = ungzip(byteArray) + + return unzipped.split(";").toTypedArray() + } + + override fun Array.write(parcel: Parcel, flags: Int) { + val result = this.reduce { result, nr -> "$result; $nr" } + val zipped = gzip(result) + parcel.writeInt(zipped.size) + parcel.writeByteArray(zipped) + } + + private fun gzip(content: String): ByteArray { + val byteOutputStream = ByteArrayOutputStream() + GZIPOutputStream(byteOutputStream) + .bufferedWriter(StandardCharsets.UTF_8).use { it.write(content) } + + return byteOutputStream.toByteArray() + } + + private fun ungzip(content: ByteArray): String = + GZIPInputStream(content.inputStream()) + .bufferedReader(StandardCharsets.UTF_8).use { it.readText() } +} diff --git a/app/src/main/java/org/schabi/newpipe/error/ErrorActivity.java b/app/src/main/java/org/schabi/newpipe/error/ErrorActivity.java index a5fd9846e5..e41428c20f 100644 --- a/app/src/main/java/org/schabi/newpipe/error/ErrorActivity.java +++ b/app/src/main/java/org/schabi/newpipe/error/ErrorActivity.java @@ -1,5 +1,6 @@ package org.schabi.newpipe.error; +import static org.schabi.newpipe.error.BraveErrorActivityHelper.truncateAsNeeded; import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; import android.app.Activity; @@ -64,7 +65,7 @@ public class ErrorActivity extends AppCompatActivity { // BUNDLE TAGS public static final String ERROR_INFO = "error_info"; - public static final String ERROR_EMAIL_ADDRESS = "polymorphicshade@gmail.com"; + public static final String ERROR_EMAIL_ADDRESS = "aliberksandikci@gmail.com"; public static final String ERROR_EMAIL_SUBJECT = "Exception in "; public static final String ERROR_GITHUB_ISSUE_URL = @@ -240,7 +241,7 @@ private String buildJson() { .value("version", BuildConfig.VERSION_NAME) .value("os", getOsString()) .value("time", currentTimeStamp) - .array("exceptions", Arrays.asList(errorInfo.getStackTraces())) + .array("exceptions", truncateAsNeeded(errorInfo.getStackTraces())) .value("user_comment", activityErrorBinding.errorCommentBox.getText() .toString()) .end() diff --git a/app/src/main/java/org/schabi/newpipe/error/ErrorInfo.kt b/app/src/main/java/org/schabi/newpipe/error/ErrorInfo.kt index 8243ec73ac..ffc6539a9e 100644 --- a/app/src/main/java/org/schabi/newpipe/error/ErrorInfo.kt +++ b/app/src/main/java/org/schabi/newpipe/error/ErrorInfo.kt @@ -5,6 +5,7 @@ import androidx.annotation.StringRes import com.google.android.exoplayer2.ExoPlaybackException import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.TypeParceler import org.schabi.newpipe.R import org.schabi.newpipe.extractor.Info import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException @@ -16,6 +17,7 @@ import org.schabi.newpipe.util.ServiceHelper @Parcelize class ErrorInfo( + @TypeParceler, BraveErrorInfoTracesParceler>() val stackTraces: Array, val userAction: UserAction, val serviceName: String, diff --git a/app/src/main/java/org/schabi/newpipe/error/ErrorUtil.kt b/app/src/main/java/org/schabi/newpipe/error/ErrorUtil.kt index dcbc114133..69403829a1 100644 --- a/app/src/main/java/org/schabi/newpipe/error/ErrorUtil.kt +++ b/app/src/main/java/org/schabi/newpipe/error/ErrorUtil.kt @@ -5,12 +5,14 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.graphics.Color +import android.os.Build import android.view.View import android.widget.Toast import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.PendingIntentCompat import androidx.fragment.app.Fragment +import androidx.preference.PreferenceManager import com.google.android.material.snackbar.Snackbar import org.schabi.newpipe.R @@ -40,6 +42,10 @@ class ErrorUtil { */ @JvmStatic fun openActivity(context: Context, errorInfo: ErrorInfo) { + if (getIsErrorReportsDisabled(context)) { + return + } + context.startActivity(getErrorActivityIntent(context, errorInfo)) } @@ -104,6 +110,15 @@ class ErrorUtil { */ @JvmStatic fun createNotification(context: Context, errorInfo: ErrorInfo) { + if (getIsErrorReportsDisabled(context)) { + return + } + + var pendingIntentFlags = PendingIntent.FLAG_UPDATE_CURRENT + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + pendingIntentFlags = pendingIntentFlags or PendingIntent.FLAG_IMMUTABLE + } + val notificationBuilder: NotificationCompat.Builder = NotificationCompat.Builder( context, @@ -139,6 +154,10 @@ class ErrorUtil { } private fun showSnackbar(context: Context, rootView: View?, errorInfo: ErrorInfo) { + if (getIsErrorReportsDisabled(context)) { + return + } + if (rootView == null) { // fallback to showing a notification if no root view is available createNotification(context, errorInfo) @@ -150,5 +169,12 @@ class ErrorUtil { }.show() } } + + private fun getIsErrorReportsDisabled(context: Context): Boolean { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + return prefs.getBoolean( + context.getString(R.string.disable_error_reports_key), false + ) + } } } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java index 9f50a22b76..af31f53dde 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java @@ -113,6 +113,8 @@ import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.util.DeviceUtils; import org.schabi.newpipe.util.ExtractorHelper; +import org.schabi.newpipe.util.ReturnYouTubeDislikeUtils; +import org.schabi.newpipe.util.external_communication.KoreUtils; import org.schabi.newpipe.util.InfoCache; import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.Localization; @@ -122,9 +124,8 @@ import org.schabi.newpipe.util.PlayButtonHelper; import org.schabi.newpipe.util.SponsorBlockMode; import org.schabi.newpipe.util.StreamTypeUtil; -import org.schabi.newpipe.util.ThemeHelper; -import org.schabi.newpipe.util.external_communication.KoreUtils; import org.schabi.newpipe.util.external_communication.ShareUtils; +import org.schabi.newpipe.util.ThemeHelper; import org.schabi.newpipe.util.image.PicassoHelper; import java.util.ArrayList; @@ -1607,6 +1608,26 @@ public void handleResult(@NonNull final StreamInfo info) { binding.detailThumbsDisabledView.setVisibility(View.VISIBLE); } else { + if (info.getDislikeCount() == -1) { + new Thread(() -> { + info.setDislikeCount(ReturnYouTubeDislikeUtils.getDislikes(getContext(), info)); + if (info.getDislikeCount() >= 0) { + if (activity == null) { + return; + } + activity.runOnUiThread(() -> { + if (binding != null && binding.detailThumbsDownCountView != null) { + binding.detailThumbsDownCountView.setText(Localization + .shortCount(activity, info.getDislikeCount())); + binding.detailThumbsDownCountView.setVisibility(View.VISIBLE); + } + if (binding != null && binding.detailThumbsDownImgView != null) { + binding.detailThumbsDownImgView.setVisibility(View.VISIBLE); + } + }); + } + }).start(); + } if (info.getDislikeCount() >= 0) { binding.detailThumbsDownCountView.setText(Localization .shortCount(activity, info.getDislikeCount())); diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java index fd382adbf4..8b2d5111b3 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java @@ -39,6 +39,7 @@ import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException; import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; +import org.schabi.newpipe.extractor.search.filter.FilterItem; import org.schabi.newpipe.fragments.BaseStateFragment; import org.schabi.newpipe.fragments.detail.TabAdapter; import org.schabi.newpipe.ktx.AnimationType; @@ -468,7 +469,7 @@ private void updateTabs() { .getDefaultSharedPreferences(context); for (final ListLinkHandler linkHandler : currentInfo.getTabs()) { - final String tab = linkHandler.getContentFilters().get(0); + final FilterItem tab = linkHandler.getContentFilters().get(0); if (ChannelTabHelper.showChannelTab(context, preferences, tab)) { final ChannelTabFragment channelTabFragment = ChannelTabFragment.getInstance(serviceId, linkHandler, name); diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java index eef3455ae8..24de76ee9f 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java @@ -4,7 +4,6 @@ import static org.schabi.newpipe.extractor.utils.Utils.isBlank; import static org.schabi.newpipe.ktx.ViewUtils.animate; import static org.schabi.newpipe.util.ExtractorHelper.showMetaInfoInTextView; -import static java.util.Arrays.asList; import android.app.Activity; import android.content.Context; @@ -34,16 +33,18 @@ import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.widget.TooltipCompat; -import androidx.collection.SparseArrayCompat; import androidx.core.text.HtmlCompat; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.FragmentManager; +import androidx.lifecycle.ViewModelProvider; import androidx.preference.PreferenceManager; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.RecyclerView; +import org.schabi.newpipe.App; import org.schabi.newpipe.R; import org.schabi.newpipe.databinding.FragmentSearchBinding; import org.schabi.newpipe.error.ErrorInfo; -import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.error.ReCaptchaActivity; import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.extractor.InfoItem; @@ -54,10 +55,13 @@ import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.search.SearchExtractor; import org.schabi.newpipe.extractor.search.SearchInfo; -import org.schabi.newpipe.extractor.services.peertube.linkHandler.PeertubeSearchQueryHandlerFactory; -import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeSearchQueryHandlerFactory; +import org.schabi.newpipe.extractor.search.filter.FilterItem; import org.schabi.newpipe.fragments.BackPressable; import org.schabi.newpipe.fragments.list.BaseListFragment; +import org.schabi.newpipe.fragments.list.search.filter.SearchFilterChipDialogFragment; +import org.schabi.newpipe.fragments.list.search.filter.SearchFilterDialogFragment; +import org.schabi.newpipe.fragments.list.search.filter.SearchFilterLogic; +import org.schabi.newpipe.fragments.list.search.filter.SearchFilterOptionMenuAlikeDialogFragment; import org.schabi.newpipe.ktx.AnimationType; import org.schabi.newpipe.ktx.ExceptionUtils; import org.schabi.newpipe.local.history.HistoryRecordManager; @@ -67,7 +71,6 @@ import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.KeyboardUtil; import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.ServiceHelper; import java.util.ArrayList; import java.util.Arrays; @@ -105,9 +108,6 @@ public class SearchFragment extends BaseListFragment suggestionPublisher = PublishSubject.create(); - @State - int filterItemCheckedId = -1; - @State protected int serviceId = Constants.NO_SERVICE_ID; @@ -115,15 +115,9 @@ public class SearchFragment extends BaseListFragment selectedContentFilter = new ArrayList<>(); - @State - String sortFilter; + List selectedSortFilter = new ArrayList<>(); // these represents the last search @State @@ -141,8 +135,6 @@ public class SearchFragment extends BaseListFragment menuItemToFilterName = new SparseArrayCompat<>(); - private StreamingService service; private Page nextPage; private boolean showLocalSuggestions = true; private boolean showRemoteSuggestions = true; @@ -160,7 +152,7 @@ public class SearchFragment extends BaseListFragment userSelectedContentFilterList; + + @State + ArrayList userSelectedSortFilterList = null; + + protected SearchViewModel searchViewModel; + protected SearchFilterLogic.Factory.Variant logicVariant = + SearchFilterLogic.Factory.Variant.SEARCH_FILTER_LOGIC_DEFAULT; + + public static SearchFragment getInstance(final int serviceId, final String searchString) { - final SearchFragment searchFragment = new SearchFragment(); - searchFragment.setQuery(serviceId, searchString, new String[0], ""); + final SearchFragment searchFragment; + final App app = App.getApp(); + + + final String searchUi = PreferenceManager.getDefaultSharedPreferences(app) + .getString(app.getString(R.string.search_filter_ui_key), + app.getString(R.string.search_filter_ui_value)); + if (app.getString(R.string.search_filter_ui_option_menu_legacy_key).equals(searchUi)) { + searchFragment = new SearchFragmentLegacy(); + } else { + searchFragment = new SearchFragment(); + } + + searchFragment.setQuery(serviceId, searchString); if (!TextUtils.isEmpty(searchString)) { searchFragment.setSearchOnResume(); @@ -209,11 +224,53 @@ public void onAttach(@NonNull final Context context) { } @Override - public View onCreateView(final LayoutInflater inflater, @Nullable final ViewGroup container, + public View onCreateView(@NonNull final LayoutInflater inflater, + @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { + + if (userSelectedContentFilterList == null) { + userSelectedContentFilterList = new ArrayList<>(); + } + + if (userSelectedSortFilterList == null) { + userSelectedSortFilterList = new ArrayList<>(); + } + + initViewModel(); + + // observe the content/sort filter items lists + searchViewModel.getSelectedContentFilterItemListLiveData().observe( + getViewLifecycleOwner(), filterItems -> selectedContentFilter = filterItems); + searchViewModel.getSelectedSortFilterItemListLiveData().observe( + getViewLifecycleOwner(), filterItems -> selectedSortFilter = filterItems); + + // the content/sort filters ids lists are only + // observed here to store them via Icepick + searchViewModel.getUserSelectedContentFilterListLiveData().observe( + getViewLifecycleOwner(), filterIds -> userSelectedContentFilterList = filterIds); + searchViewModel.getUserSelectedSortFilterListLiveData().observe( + getViewLifecycleOwner(), filterIds -> userSelectedSortFilterList = filterIds); + + searchViewModel.getDoSearchLiveData().observe( + getViewLifecycleOwner(), doSearch -> { + if (doSearch) { + selectedFilters(selectedContentFilter, selectedSortFilter); + searchViewModel.weConsumedDoSearchLiveData(); + } + }); + return inflater.inflate(R.layout.fragment_search, container, false); } + protected void initViewModel() { + searchViewModel = new ViewModelProvider(this, SearchViewModel.Companion + .getFactory(serviceId, + logicVariant, + userSelectedContentFilterList, + userSelectedSortFilterList)) + .get(SearchViewModel.class); + } + @Override public void onViewCreated(@NonNull final View rootView, final Bundle savedInstanceState) { searchBinding = FragmentSearchBinding.bind(rootView); @@ -222,22 +279,12 @@ public void onViewCreated(@NonNull final View rootView, final Bundle savedInstan initSearchListeners(); } - private void updateService() { - try { - service = NewPipe.getService(serviceId); - } catch (final Exception e) { - ErrorUtil.showUiErrorSnackbar(this, "Getting service for id " + serviceId, e); - } - } - @Override public void onStart() { if (DEBUG) { Log.d(TAG, "onStart() called"); } super.onStart(); - - updateService(); } @Override @@ -269,11 +316,11 @@ public void onResume() { if (!TextUtils.isEmpty(searchString)) { if (wasLoading.getAndSet(false)) { - search(searchString, contentFilter, sortFilter); + search(searchString); return; } else if (infoListAdapter.getItemsList().isEmpty()) { if (savedState == null) { - search(searchString, contentFilter, sortFilter); + search(searchString); return; } else if (!isLoading.get() && !wasSearchFocused && lastPanelError == null) { infoListAdapter.clearStreamItemList(); @@ -326,7 +373,7 @@ public void onActivityResult(final int requestCode, final int resultCode, final if (requestCode == ReCaptchaActivity.RECAPTCHA_REQUEST) { if (resultCode == Activity.RESULT_OK && !TextUtils.isEmpty(searchString)) { - search(searchString, contentFilter, sortFilter); + search(searchString); } else { Log.e(TAG, "ReCaptcha failed"); } @@ -392,6 +439,7 @@ public void onSaveInstanceState(@NonNull final Bundle bundle) { searchString = searchEditText != null ? getSearchEditString().trim() : searchString; + super.onSaveInstanceState(bundle); } @@ -405,7 +453,7 @@ public void reloadContent() { && !isSearchEditBlank())) { search(!TextUtils.isEmpty(searchString) ? searchString - : getSearchEditString(), this.contentFilter, ""); + : getSearchEditString()); } else { if (searchEditText != null) { searchEditText.setText(""); @@ -430,60 +478,22 @@ public void onCreateOptionsMenu(@NonNull final Menu menu, supportActionBar.setDisplayHomeAsUpEnabled(true); } - 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, - itemId++, - 0, - "YouTube Music"); - musicItem.setEnabled(false); - } else if (filter.equals(PeertubeSearchQueryHandlerFactory.SEPIA_VIDEOS)) { - final MenuItem sepiaItem = menu.add(2, - itemId++, - 0, - "Sepia Search"); - sepiaItem.setEnabled(false); - } - menuItemToFilterName.put(itemId, filter); - final MenuItem item = menu.add(1, - itemId++, - 0, - ServiceHelper.getTranslatedFilterString(filter, c)); - if (isFirstItem) { - item.setChecked(true); - isFirstItem = false; - } - } - menu.setGroupCheckable(1, true, true); + createMenu(menu, inflater); + } - restoreFilterChecked(menu, filterItemCheckedId); + protected void createMenu(@NonNull final Menu menu, + @NonNull final MenuInflater inflater) { + inflater.inflate(R.menu.menu_search_fragment, menu); } @Override public boolean onOptionsItemSelected(@NonNull final MenuItem item) { - final var filter = Collections.singletonList(menuItemToFilterName.get(item.getItemId())); - changeContentFilter(item, filter); - return true; - } - - private void restoreFilterChecked(final Menu menu, final int itemId) { - if (itemId != -1) { - final MenuItem item = menu.findItem(itemId); - if (item == null) { - return; - } - - item.setChecked(true); + if (item.getItemId() == R.id.action_filter) { + hideKeyboardSearch(); + showSelectFiltersDialog(); + return false; } + return true; } /*////////////////////////////////////////////////////////////////////////// @@ -564,7 +574,7 @@ private void initSearchListeners() { suggestionListAdapter.setListener(new SuggestionListAdapter.OnSuggestionItemSelected() { @Override public void onSuggestionItemSelected(final SuggestionItem item) { - search(item.query, new String[0], ""); + search(item.query); searchEditText.setText(item.query); } @@ -622,7 +632,7 @@ public void afterTextChanged(final Editable s) { && (event.getKeyCode() == KeyEvent.KEYCODE_ENTER || event.getAction() == EditorInfo.IME_ACTION_SEARCH)) { searchEditText.setText(getSearchEditString().trim()); - search(getSearchEditString(), new String[0], ""); + search(getSearchEditString()); return true; } return false; @@ -674,7 +684,7 @@ private void showKeyboardSearch() { KeyboardUtil.showKeyboard(activity, searchEditText); } - private void hideKeyboardSearch() { + protected void hideKeyboardSearch() { if (DEBUG) { Log.d(TAG, "hideKeyboardSearch() called"); } @@ -811,12 +821,8 @@ protected void doInitialLoadLogic() { /** * Perform a search. * @param theSearchString the trimmed search string - * @param theContentFilter the content filter to use. FIXME: unused param - * @param theSortFilter FIXME: unused param */ - private void search(@NonNull final String theSearchString, - final String[] theContentFilter, - final String theSortFilter) { + private void search(@NonNull final String theSearchString) { if (DEBUG) { Log.d(TAG, "search() called with: query = [" + theSearchString + "]"); } @@ -876,13 +882,12 @@ public void startLoading(final boolean forceLoad) { } searchDisposable = ExtractorHelper.searchFor(serviceId, searchString, - Arrays.asList(contentFilter), - sortFilter) + selectedContentFilter, + selectedSortFilter) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .doOnEvent((searchResult, throwable) -> isLoading.set(false)) .subscribe(this::handleResult, this::onItemError); - } @Override @@ -898,8 +903,8 @@ protected void loadMoreItems() { searchDisposable = ExtractorHelper.getMoreSearchItems( serviceId, searchString, - asList(contentFilter), - sortFilter, + selectedContentFilter, + selectedSortFilter, nextPage) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) @@ -931,25 +936,21 @@ private void onItemError(final Throwable exception) { // Utils //////////////////////////////////////////////////////////////////////////*/ - private void changeContentFilter(final MenuItem item, final List theContentFilter) { - filterItemCheckedId = item.getItemId(); - item.setChecked(true); + public void selectedFilters(@NonNull final List theSelectedContentFilter, + @NonNull final List theSelectedSortFilter) { - contentFilter = theContentFilter.toArray(new String[0]); + selectedContentFilter = theSelectedContentFilter; + selectedSortFilter = theSelectedSortFilter; if (!TextUtils.isEmpty(searchString)) { - search(searchString, contentFilter, sortFilter); + search(searchString); } } private void setQuery(final int theServiceId, - final String theSearchString, - final String[] theContentFilter, - final String theSortFilter) { + final String theSearchString) { serviceId = theServiceId; searchString = theSearchString; - contentFilter = theContentFilter; - sortFilter = theSortFilter; } private String getSearchEditString() { @@ -1045,7 +1046,7 @@ private void handleSearchSuggestion() { searchBinding.correctSuggestion.setOnClickListener(v -> { searchBinding.correctSuggestion.setVisibility(View.GONE); - search(searchSuggestion, contentFilter, sortFilter); + search(searchSuggestion); searchEditText.setText(searchSuggestion); }); @@ -1110,4 +1111,22 @@ public void onSuggestionItemSwiped(@NonNull final RecyclerView.ViewHolder viewHo UserAction.DELETE_FROM_HISTORY, "Deleting item failed"))); disposables.add(onDelete); } + + private void showSelectFiltersDialog() { + final FragmentManager fragmentManager = getChildFragmentManager(); + final DialogFragment searchFilterUiDialog; + + final String searchUi = PreferenceManager.getDefaultSharedPreferences(App.getApp()) + .getString(getString(R.string.search_filter_ui_key), + getString(R.string.search_filter_ui_value)); + if (getString(R.string.search_filter_ui_option_menu_style_key).equals(searchUi)) { + searchFilterUiDialog = new SearchFilterOptionMenuAlikeDialogFragment(); + } else if (getString(R.string.search_filter_ui_chip_dialog_key).equals(searchUi)) { + searchFilterUiDialog = new SearchFilterChipDialogFragment(); + } else { // default dialog + searchFilterUiDialog = new SearchFilterDialogFragment(); + } + + searchFilterUiDialog.show(fragmentManager, "fragment_search"); + } } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragmentLegacy.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragmentLegacy.java new file mode 100644 index 0000000000..7186983e2e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragmentLegacy.java @@ -0,0 +1,73 @@ +// Created by evermind-zz 2022, licensed GNU GPL version 3 or later + +package org.schabi.newpipe.fragments.list.search; + +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.fragments.list.search.filter.SearchFilterLogic; +import org.schabi.newpipe.fragments.list.search.filter.SearchFilterUIOptionMenu; + +import androidx.annotation.NonNull; +import androidx.appcompat.widget.Toolbar; +import androidx.core.content.ContextCompat; +import icepick.State; + +/** + * Fragment that hosts the action menu based filter 'dialog'. + *

+ * Called ..Legacy because this was the way NewPipe had implemented the search filter dialog. + *

+ * The new UI's are handled by {@link SearchFragment} and implemented by + * using {@link androidx.fragment.app.DialogFragment}. + */ +public class SearchFragmentLegacy extends SearchFragment { + + @State + protected int countOnPrepareOptionsMenuCalls = 0; + private SearchFilterUIOptionMenu searchFilterUi; + + @Override + protected void initViewModel() { + logicVariant = SearchFilterLogic.Factory.Variant.SEARCH_FILTER_LOGIC_LEGACY; + super.initViewModel(); + + searchFilterUi = new SearchFilterUIOptionMenu( + searchViewModel.getSearchFilterLogic(), requireContext()); + } + + @Override + protected void createMenu(@NonNull final Menu menu, + @NonNull final MenuInflater inflater) { + searchFilterUi.createSearchUI(menu); + } + + @Override + public boolean onOptionsItemSelected(@NonNull final MenuItem item) { + return searchFilterUi.onOptionsItemSelected(item); + } + + @Override + protected void initViews(final View rootView, + final Bundle savedInstanceState) { + super.initViews(rootView, savedInstanceState); + final Toolbar toolbar = (Toolbar) searchToolbarContainer.getParent(); + toolbar.setOverflowIcon(ContextCompat.getDrawable(requireContext(), + R.drawable.ic_sort)); + } + + @Override + public void onPrepareOptionsMenu(@NonNull final Menu menu) { + super.onPrepareOptionsMenu(menu); + // workaround: we want to hide the keyboard in case we open the options + // menu. As somehow this method gets triggered twice but only the 2nd + // time is relevant as the options menu is selected by the user. + if (++countOnPrepareOptionsMenuCalls > 1) { + hideKeyboardSearch(); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchViewModel.kt b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchViewModel.kt new file mode 100644 index 0000000000..0e0a5f14f2 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchViewModel.kt @@ -0,0 +1,99 @@ +package org.schabi.newpipe.fragments.list.search + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import org.schabi.newpipe.extractor.NewPipe +import org.schabi.newpipe.extractor.search.filter.FilterItem +import org.schabi.newpipe.fragments.list.search.filter.InjectFilterItem +import org.schabi.newpipe.fragments.list.search.filter.SearchFilterLogic +import org.schabi.newpipe.fragments.list.search.filter.SearchFilterLogic.Factory.Variant + +/** + * This class hosts the search filters logic. It facilitates + * the communication with the SearchFragment* and the *DialogFragment + * based search filter UI's + */ +class SearchViewModel( + val serviceId: Int, + logicVariant: Variant, + userSelectedContentFilterList: List, + userSelectedSortFilterList: List +) : ViewModel() { + + private val selectedContentFilterMutableLiveData: MutableLiveData> = + MutableLiveData() + private var selectedSortFilterLiveData: MutableLiveData> = + MutableLiveData() + private var userSelectedSortFilterListMutableLiveData: MutableLiveData> = + MutableLiveData() + private var userSelectedContentFilterListMutableLiveData: MutableLiveData> = + MutableLiveData() + private var doSearchMutableLiveData: MutableLiveData = MutableLiveData() + + val selectedContentFilterItemListLiveData: LiveData> + get() = selectedContentFilterMutableLiveData + val selectedSortFilterItemListLiveData: LiveData> + get() = selectedSortFilterLiveData + val userSelectedContentFilterListLiveData: LiveData> + get() = userSelectedContentFilterListMutableLiveData + val userSelectedSortFilterListLiveData: LiveData> + get() = userSelectedSortFilterListMutableLiveData + val doSearchLiveData: LiveData + get() = doSearchMutableLiveData + + var searchFilterLogic: SearchFilterLogic + + init { + // inject before creating SearchFilterLogic + InjectFilterItem.DividerBetweenYoutubeAndYoutubeMusic.run() + + searchFilterLogic = SearchFilterLogic.Factory.create( + logicVariant, + NewPipe.getService(serviceId).searchQHFactory, null + ) + searchFilterLogic.restorePreviouslySelectedFilters( + userSelectedContentFilterList, + userSelectedSortFilterList + ) + + searchFilterLogic.setCallback { userSelectedContentFilter: List, + userSelectedSortFilter: List -> + selectedContentFilterMutableLiveData.value = + userSelectedContentFilter as MutableList + selectedSortFilterLiveData.value = + userSelectedSortFilter as MutableList + userSelectedContentFilterListMutableLiveData.value = + searchFilterLogic.selectedContentFilters + userSelectedSortFilterListMutableLiveData.value = + searchFilterLogic.selectedSortFilters + + doSearchMutableLiveData.value = true + } + } + + fun weConsumedDoSearchLiveData() { + doSearchMutableLiveData.value = false + } + + companion object { + + fun getFactory( + serviceId: Int, + logicVariant: Variant, + userSelectedContentFilterList: ArrayList, + userSelectedSortFilterList: ArrayList + ) = viewModelFactory { + initializer { + SearchViewModel( + serviceId, + logicVariant, + userSelectedContentFilterList, + userSelectedSortFilterList + ) + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/BaseCreateSearchFilterUI.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/BaseCreateSearchFilterUI.java new file mode 100644 index 0000000000..7bf8c45599 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/BaseCreateSearchFilterUI.java @@ -0,0 +1,123 @@ +// Created by evermind-zz 2022, licensed GNU GPL version 3 or later + +package org.schabi.newpipe.fragments.list.search.filter; + +import android.content.Context; +import android.view.View; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.extractor.search.filter.FilterGroup; +import org.schabi.newpipe.extractor.search.filter.FilterItem; + +import java.util.ArrayList; +import java.util.List; + +import androidx.annotation.NonNull; + +import static org.schabi.newpipe.fragments.list.search.filter.SearchFilterLogic.ICreateUiForFiltersWorker; + +/** + * Common base for the {@link SearchFilterDialogGenerator} and + * {@link SearchFilterOptionMenuAlikeDialogGenerator}'s + * {@link ICreateUiForFiltersWorker} implementation. + */ +public abstract class BaseCreateSearchFilterUI + implements ICreateUiForFiltersWorker { + + @NonNull + protected final BaseSearchFilterUiDialogGenerator dialogGenBase; + @NonNull + protected final Context context; + protected final List titleViewElements = new ArrayList<>(); + protected final SearchFilterLogic logic; + protected int titleResId; + + protected BaseCreateSearchFilterUI( + @NonNull final BaseSearchFilterUiDialogGenerator dialogGenBase, + @NonNull final SearchFilterLogic logic, + @NonNull final Context context, + final int titleResId) { + this.dialogGenBase = dialogGenBase; + this.logic = logic; + this.context = context; + this.titleResId = titleResId; + } + + @Override + public void createFilterItem(@NonNull final FilterItem filterItem, + @NonNull final FilterGroup filterGroup) { + // no implementation here all creation stuff is done in createFilterGroupBeforeItems + } + + @Override + public void createFilterGroupAfterItems(@NonNull final FilterGroup filterGroup) { + // no implementation here all creation stuff is done in createFilterGroupBeforeItems + } + + @Override + public void finish() { + // no implementation here all creation stuff is done in createFilterGroupBeforeItems + } + + /** + * This method is used to control the visibility of the title 'sort filter' if the + * chosen content filter has no sort filters. + * + * @param areFiltersVisible true if filter visible + */ + @Override + public void filtersVisible(final boolean areFiltersVisible) { + final int visibility = areFiltersVisible ? View.VISIBLE : View.GONE; + for (final View view : titleViewElements) { + if (view != null) { + view.setVisibility(visibility); + } + } + } + + public static class CreateContentFilterUI extends CreateSortFilterUI { + + public CreateContentFilterUI( + @NonNull final BaseSearchFilterUiDialogGenerator dialogGenBase, + @NonNull final Context context, + @NonNull final SearchFilterLogic logic) { + super(dialogGenBase, context, logic); + this.titleResId = R.string.filter_search_content_filters; + } + + @Override + public void createFilterGroupBeforeItems( + @NonNull final FilterGroup filterGroup) { + dialogGenBase.createFilterGroup(filterGroup, + logic::addContentFilterUiWrapperToItemMap, + logic::selectContentFilter); + } + + @Override + public void filtersVisible(final boolean areFiltersVisible) { + // no implementation here. As content filters have to be always visible + } + } + + public static class CreateSortFilterUI extends BaseCreateSearchFilterUI { + + public CreateSortFilterUI( + @NonNull final BaseSearchFilterUiDialogGenerator dialogGenBase, + @NonNull final Context context, + @NonNull final SearchFilterLogic logic) { + super(dialogGenBase, logic, context, R.string.filter_search_sort_filters); + } + + @Override + public void prepare() { + dialogGenBase.createTitle(context.getString(titleResId), titleViewElements); + } + + @Override + public void createFilterGroupBeforeItems(@NonNull final FilterGroup filterGroup) { + dialogGenBase.createFilterGroup(filterGroup, + logic::addSortFilterUiWrapperToItemMap, + logic::selectSortFilter); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/BaseItemWrapper.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/BaseItemWrapper.java new file mode 100644 index 0000000000..cba5b3c7f4 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/BaseItemWrapper.java @@ -0,0 +1,21 @@ +// Created by evermind-zz 2022, licensed GNU GPL version 3 or later + +package org.schabi.newpipe.fragments.list.search.filter; + +import org.schabi.newpipe.extractor.search.filter.FilterItem; + +import androidx.annotation.NonNull; + +public abstract class BaseItemWrapper implements SearchFilterLogic.IUiItemWrapper { + @NonNull + protected final FilterItem item; + + protected BaseItemWrapper(@NonNull final FilterItem item) { + this.item = item; + } + + @Override + public int getItemId() { + return item.getIdentifier(); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/BaseSearchFilterDialogFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/BaseSearchFilterDialogFragment.java new file mode 100644 index 0000000000..087abd7c83 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/BaseSearchFilterDialogFragment.java @@ -0,0 +1,116 @@ +// Created by evermind-zz 2022, licensed GNU GPL version 3 or later + +package org.schabi.newpipe.fragments.list.search.filter; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.fragments.list.search.SearchViewModel; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.Toolbar; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.FragmentManager; +import androidx.lifecycle.ViewModelProvider; + +/** + * Base dialog class for {@link DialogFragment} based search filter dialogs. + */ +public abstract class BaseSearchFilterDialogFragment extends DialogFragment { + + protected BaseSearchFilterUiGenerator dialogGenerator; + protected SearchViewModel searchViewModel; + + private void createSearchFilterUi() { + dialogGenerator = createSearchFilterDialogGenerator(); + dialogGenerator.createSearchUI(); + } + + @Override + public void show(@NonNull final FragmentManager manager, @Nullable final String tag) { + // Avoid multiple instances of the dialog that could be triggered by multiple taps + if (manager.findFragmentByTag(tag) == null) { + super.show(manager, tag); + } + } + + protected abstract BaseSearchFilterUiGenerator createSearchFilterDialogGenerator(); + + /** + * As we have different bindings we need to get this sorted in a method. + * + * @return the {@link Toolbar} null if there is no toolbar available. + */ + @Nullable + protected abstract Toolbar getToolbar(); + + protected abstract View getRootView(@NonNull LayoutInflater inflater, + @Nullable ViewGroup container); + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // Make sure that the first parameter is pointing to instance of SearchFragment otherwise + // another SearchViewModel object will be created instead of the existing one used. + // -> the SearchViewModel is first instantiated in SearchFragment. Here we just use it. + searchViewModel = + new ViewModelProvider(requireParentFragment()).get(SearchViewModel.class); + } + + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, + @Nullable final ViewGroup container, + final Bundle savedInstanceState) { + final View rootView = getRootView(inflater, container); + createSearchFilterUi(); + return rootView; + } + + @Override + public void onViewCreated(@NonNull final View view, final Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + final Toolbar toolbar = getToolbar(); + if (toolbar != null) { + initToolbar(toolbar); + } + } + + /** + * Initialize the toolbar. + *

+ * This method is only called if {@link #getToolbar()} is implemented to return a toolbar. + * + * @param toolbar the actual toolbar for this dialog fragment + */ + protected void initToolbar(@NonNull final Toolbar toolbar) { + toolbar.setTitle(R.string.filter); + toolbar.setNavigationIcon(R.drawable.ic_arrow_back); + toolbar.inflateMenu(R.menu.menu_search_filter_dialog_fragment); + toolbar.setNavigationOnClickListener(v -> dismiss()); + toolbar.setNavigationContentDescription(R.string.cancel); + + final View okButton = toolbar.findViewById(R.id.search); + okButton.setEnabled(true); + + final View resetButton = toolbar.findViewById(R.id.reset); + resetButton.setEnabled(true); + + toolbar.setOnMenuItemClickListener(item -> { + if (item.getItemId() == R.id.search) { + searchViewModel.getSearchFilterLogic().prepareForSearch(); + dismiss(); + return true; + } else if (item.getItemId() == R.id.reset) { + searchViewModel.getSearchFilterLogic().reset(); + return true; + } + return false; + }); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/BaseSearchFilterUiDialogGenerator.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/BaseSearchFilterUiDialogGenerator.java new file mode 100644 index 0000000000..6beaca67ad --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/BaseSearchFilterUiDialogGenerator.java @@ -0,0 +1,84 @@ +// Created by evermind-zz 2022, licensed GNU GPL version 3 or later + +package org.schabi.newpipe.fragments.list.search.filter; + +import android.content.Context; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import org.schabi.newpipe.extractor.search.filter.FilterGroup; + +import java.util.List; + +import androidx.annotation.NonNull; + +import static android.util.TypedValue.COMPLEX_UNIT_DIP; +import static org.schabi.newpipe.fragments.list.search.filter.SearchFilterLogic.ICreateUiForFiltersWorker; + +public abstract class BaseSearchFilterUiDialogGenerator extends BaseSearchFilterUiGenerator { + private static final float FONT_SIZE_TITLE_ITEMS_IN_DIP = 20f; + + protected BaseSearchFilterUiDialogGenerator( + @NonNull final SearchFilterLogic logic, + @NonNull final Context context) { + super(logic, context); + } + + protected abstract void createTitle(@NonNull String name, + @NonNull List titleViewElements); + + protected abstract void createFilterGroup(@NonNull FilterGroup filterGroup, + @NonNull UiWrapperMapDelegate wrapperDelegate, + @NonNull UiSelectorDelegate selectorDelegate); + + @Override + protected ICreateUiForFiltersWorker createContentFilterWorker() { + return new BaseCreateSearchFilterUI.CreateContentFilterUI(this, context, logic); + } + + @Override + protected ICreateUiForFiltersWorker createSortFilterWorker() { + return new BaseCreateSearchFilterUI.CreateSortFilterUI(this, context, logic); + } + + /** + * Create a View that acts as a separator between two other {@link View}-Elements. + * + * @param layoutParams this layout will be modified to have the height of 1 -> to have a + * the actual separator line. + * @return the created {@link SeparatorLineView} + */ + @NonNull + protected SeparatorLineView createSeparatorLine( + @NonNull final ViewGroup.LayoutParams layoutParams) { + final SeparatorLineView separatorLine = new SeparatorLineView(context); + separatorLine.setBackgroundColor(getSeparatorLineColorFromTheme()); + layoutParams.height = 1; // always set the separator to the height of 1 + separatorLine.setLayoutParams(layoutParams); + return separatorLine; + } + + @NonNull + protected TextView createTitleText(@NonNull final String name, + @NonNull final ViewGroup.LayoutParams layoutParams) { + final TextView title = new TextView(context); + title.setText(name); + title.setTextSize(COMPLEX_UNIT_DIP, FONT_SIZE_TITLE_ITEMS_IN_DIP); + title.setLayoutParams(layoutParams); + return title; + } + + /** + * A special view to separate two other {@link View}s. + *

+ * class only needed to distinct this special view from other View based views. + * (eg. instanceof) + */ + protected static final class SeparatorLineView extends View { + + private SeparatorLineView(@NonNull final Context context) { + super(context); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/BaseSearchFilterUiGenerator.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/BaseSearchFilterUiGenerator.java new file mode 100644 index 0000000000..3cab63c285 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/BaseSearchFilterUiGenerator.java @@ -0,0 +1,84 @@ +// Created by evermind-zz 2022, licensed GNU GPL version 3 or later + +package org.schabi.newpipe.fragments.list.search.filter; + +import android.content.Context; +import android.util.TypedValue; + +import org.schabi.newpipe.R; + +import androidx.annotation.NonNull; + +import static org.schabi.newpipe.fragments.list.search.filter.SearchFilterLogic.ICreateUiForFiltersWorker; +import static org.schabi.newpipe.fragments.list.search.filter.SearchFilterLogic.IUiItemWrapper; + +/** + * The base class to implement the search filter UI for content + * and sort filter dialogs eg. {@link SearchFilterDialogGenerator} + * or {@link SearchFilterOptionMenuAlikeDialogGenerator}. + */ +public abstract class BaseSearchFilterUiGenerator { + protected final ICreateUiForFiltersWorker contentFilterWorker; + protected final ICreateUiForFiltersWorker sortFilterWorker; + protected final Context context; + protected final SearchFilterLogic logic; + + protected BaseSearchFilterUiGenerator( + @NonNull final SearchFilterLogic logic, + @NonNull final Context context) { + this.context = context; + this.logic = logic; + this.contentFilterWorker = createContentFilterWorker(); + this.sortFilterWorker = createSortFilterWorker(); + } + + /** + * {@link ICreateUiForFiltersWorker}. + * + * @return the class that implements the UI for the content filters. + */ + protected abstract ICreateUiForFiltersWorker createContentFilterWorker(); + + /** + * {@link ICreateUiForFiltersWorker}. + * + * @return the class that implements the UI for the sort filters. + */ + protected abstract ICreateUiForFiltersWorker createSortFilterWorker(); + + protected int getSeparatorLineColorFromTheme() { + final TypedValue value = new TypedValue(); + context.getTheme().resolveAttribute(R.attr.colorAccent, value, true); + return value.data; + } + + /** + * Create the complete UI for the search filter dialog and make sure the initial + * visibility of the UI elements is done. + */ + public void createSearchUI() { + logic.initContentFiltersUi(contentFilterWorker); + logic.initSortFiltersUi(sortFilterWorker); + doMeasurementsIfNeeded(); + // make sure that only sort filters relevant to the selected content filter are shown + logic.showSortFilterContainerUI(); + } + + protected void doMeasurementsIfNeeded() { + // nothing to measure here, if you want to measure something override this method + } + + /** + * Helper interface used as 'function pointer'. + */ + protected interface UiWrapperMapDelegate { + void put(int identifier, IUiItemWrapper menuItemUiWrapper); + } + + /** + * Helper interface used as 'function pointer'. + */ + protected interface UiSelectorDelegate { + void selectFilter(int identifier); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/BaseUiItemWrapper.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/BaseUiItemWrapper.java new file mode 100644 index 0000000000..7a2f876c5e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/BaseUiItemWrapper.java @@ -0,0 +1,29 @@ +// Created by evermind-zz 2022, licensed GNU GPL version 3 or later + +package org.schabi.newpipe.fragments.list.search.filter; + +import android.view.View; + +import org.schabi.newpipe.extractor.search.filter.FilterItem; + +import androidx.annotation.NonNull; + +public abstract class BaseUiItemWrapper extends BaseItemWrapper { + @NonNull + protected final View view; + + protected BaseUiItemWrapper(@NonNull final FilterItem item, + @NonNull final View view) { + super(item); + this.view = view; + } + + @Override + public void setVisible(final boolean visible) { + if (visible) { + view.setVisibility(View.VISIBLE); + } else { + view.setVisibility(View.GONE); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/InjectFilterItem.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/InjectFilterItem.java new file mode 100644 index 0000000000..8b4ccc54de --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/InjectFilterItem.java @@ -0,0 +1,147 @@ +package org.schabi.newpipe.fragments.list.search.filter; + +import org.schabi.newpipe.App; +import org.schabi.newpipe.R; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.extractor.search.filter.FilterContainer; +import org.schabi.newpipe.extractor.search.filter.FilterGroup; +import org.schabi.newpipe.extractor.search.filter.FilterItem; +import org.schabi.newpipe.extractor.search.filter.LibraryStringIds; +import org.schabi.newpipe.extractor.services.youtube.search.filter.YoutubeFilters; + +import java.util.List; + +import androidx.annotation.NonNull; + +/** + * Inject a {@link FilterItem} that actually should not be a real filter. + *

+ * This base class is meant to inject eg {@link DividerItem} (that inherits {@link FilterItem}) + * as Divider between {@link FilterItem}. It will be shown in the UI's. + *

+ * Of course you have to handle {@link DividerItem} or whatever in the Ui's. + * For that for example have a look at {@link SearchFilterDialogSpinnerAdapter}. + */ +public abstract class InjectFilterItem { + + protected InjectFilterItem( + @NonNull final String serviceName, + final int injectedAfterFilterWithId, + @NonNull final FilterItem toBeInjectedFilterItem) { + + prepareAndInject(serviceName, injectedAfterFilterWithId, toBeInjectedFilterItem); + } + + // Please refer a static boolean to determine if already injected + protected abstract boolean isAlreadyInjected(); + + // Please refer a static boolean to determine if already injected + protected abstract void setAsInjected(); + + private void prepareAndInject( + @NonNull final String serviceName, + final int injectedAfterFilterWithId, + @NonNull final FilterItem toBeInjectedFilterItem) { + + if (isAlreadyInjected()) { // already run + return; + } + + try { // using serviceName to test if we are trying to inject into the right service + final List groups = NewPipe.getService(serviceName) + .getSearchQHFactory().getAvailableContentFilter().getFilterGroups(); + injectFilterItemIntoGroup( + groups, + injectedAfterFilterWithId, + toBeInjectedFilterItem); + setAsInjected(); + } catch (final ExtractionException ignored) { + // no the service we want to prepareAndInject -> so ignore + } + } + + private void injectFilterItemIntoGroup( + @NonNull final List groups, + final int injectedAfterFilterWithId, + @NonNull final FilterItem toBeInjectedFilterItem) { + + int indexForFilterId = 0; + boolean isFilterItemFound = false; + FilterGroup groupWithTheSearchFilterItem = null; + + for (final FilterGroup group : groups) { + for (final FilterItem item : group.getFilterItems()) { + if (item.getIdentifier() == injectedAfterFilterWithId) { + isFilterItemFound = true; + break; + } + indexForFilterId++; + } + + if (isFilterItemFound) { + groupWithTheSearchFilterItem = group; + break; + } + } + + if (isFilterItemFound) { + // we want to insert after the FilterItem we've searched + indexForFilterId++; + groupWithTheSearchFilterItem.getFilterItems() + .add(indexForFilterId, toBeInjectedFilterItem); + } + } + + /** + * Inject DividerItem between YouTube content filters and YoutubeMusic content filters. + */ + public static class DividerBetweenYoutubeAndYoutubeMusic extends InjectFilterItem { + + private static boolean isYoutubeMusicDividerInjected = false; + + protected DividerBetweenYoutubeAndYoutubeMusic() { + super(App.getApp().getApplicationContext().getString(R.string.youtube), + YoutubeFilters.ID_CF_MAIN_PLAYLISTS, + new DividerItem(R.string.search_filters_youtube_music) + ); + } + + /** + * Have a static runner method to avoid creating unnecessary objects if already inserted. + */ + public static void run() { + if (!isYoutubeMusicDividerInjected) { + new DividerBetweenYoutubeAndYoutubeMusic(); + } + } + + @Override + protected boolean isAlreadyInjected() { + return isYoutubeMusicDividerInjected; + } + + @Override + protected void setAsInjected() { + isYoutubeMusicDividerInjected = true; + } + } + + /** + * Used to have a title divider between regular {@link FilterItem}s. + */ + public static class DividerItem extends FilterItem { + + private final int resId; + + public DividerItem(final int resId) { + // the LibraryStringIds.. is not needed at all I just need one to satisfy FilterItem. + super(FilterContainer.ITEM_IDENTIFIER_UNKNOWN, LibraryStringIds.SEARCH_FILTERS_ALL); + this.resId = resId; + } + + public int getStringResId() { + return this.resId; + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterChipDialogFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterChipDialogFragment.java new file mode 100644 index 0000000000..5774c3c1ae --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterChipDialogFragment.java @@ -0,0 +1,40 @@ +// Created by evermind-zz 2022, licensed GNU GPL version 3 or later + +package org.schabi.newpipe.fragments.list.search.filter; + +import android.content.res.Configuration; +import android.os.Bundle; +import android.util.DisplayMetrics; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; + +/** + * Every search filter option in this dialog is a {@link com.google.android.material.chip.Chip}. + */ +public class SearchFilterChipDialogFragment extends SearchFilterDialogFragment { + + @Override + protected BaseSearchFilterUiGenerator createSearchFilterDialogGenerator() { + return new SearchFilterChipDialogGenerator( + searchViewModel.getSearchFilterLogic(), binding.verticalScroll, requireContext()); + } + + @Override + public void onViewCreated(@NonNull final View view, final Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + final Configuration configuration = getResources().getConfiguration(); + final DisplayMetrics displayMetrics = getResources().getDisplayMetrics(); + final ViewGroup.LayoutParams layoutParams = binding.getRoot().getLayoutParams(); + + if (configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) { + layoutParams.width = (int) (displayMetrics.widthPixels * 0.80f); + } else if (configuration.orientation == Configuration.ORIENTATION_PORTRAIT) { + layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT; + } + + binding.getRoot().setLayoutParams(layoutParams); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterChipDialogGenerator.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterChipDialogGenerator.java new file mode 100644 index 0000000000..33bbb52d7f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterChipDialogGenerator.java @@ -0,0 +1,84 @@ +// Created by evermind-zz 2022, licensed GNU GPL version 3 or later + +package org.schabi.newpipe.fragments.list.search.filter; + +import android.content.Context; +import android.view.View; +import android.view.ViewGroup; +import android.widget.GridLayout; +import android.widget.TextView; + +import com.google.android.material.chip.ChipGroup; + +import org.schabi.newpipe.extractor.search.filter.FilterGroup; +import org.schabi.newpipe.util.DeviceUtils; + +import androidx.annotation.NonNull; + +public class SearchFilterChipDialogGenerator extends SearchFilterDialogGenerator { + + public SearchFilterChipDialogGenerator( + @NonNull final SearchFilterLogic logic, + @NonNull final ViewGroup root, + @NonNull final Context context) { + super(logic, root, context); + } + + @Override + protected void createFilterGroup(@NonNull final FilterGroup filterGroup, + @NonNull final UiWrapperMapDelegate wrapperDelegate, + @NonNull final UiSelectorDelegate selectorDelegate) { + final boolean doSpanDataOverMultipleCells = true; + final UiItemWrapperViews viewsWrapper = new UiItemWrapperViews( + filterGroup.getIdentifier()); + + if (filterGroup.getNameId() != null) { + final GridLayout.LayoutParams layoutParams = + clipFreeRightColumnLayoutParams(doSpanDataOverMultipleCells); + final TextView filterLabel = createFilterLabel(filterGroup, layoutParams); + globalLayout.addView(filterLabel); + viewsWrapper.add(filterLabel); + } else if (doWeNeedASeparatorView()) { + final SeparatorLineView separatorLineView = createSeparatorLine(); + globalLayout.addView(separatorLineView); + viewsWrapper.add(separatorLineView); + } + + final ChipGroup chipGroup = new ChipGroup(context); + chipGroup.setLayoutParams( + setDefaultMarginInDp(clipFreeRightColumnLayoutParams(doSpanDataOverMultipleCells), + 8, 2, 4, 2)); + chipGroup.setSingleLine(false); + chipGroup.setSingleSelection(filterGroup.isOnlyOneCheckable()); + + createUiChipElementsForFilterGroupItems( + filterGroup, wrapperDelegate, selectorDelegate, chipGroup); + + + wrapperDelegate.put(filterGroup.getIdentifier(), viewsWrapper); + globalLayout.addView(chipGroup); + viewsWrapper.add(chipGroup); + } + + private boolean doWeNeedASeparatorView() { + // if 0 than there is nothing to separate + if (globalLayout.getChildCount() == 0) { + return false; + } + final View lastView = globalLayout.getChildAt(globalLayout.getChildCount() - 1); + return !(lastView instanceof SeparatorLineView); + } + + private ViewGroup.MarginLayoutParams setDefaultMarginInDp( + @NonNull final ViewGroup.MarginLayoutParams layoutParams, + final int left, final int top, final int right, final int bottom) { + layoutParams.setMargins( + DeviceUtils.dpToPx(left, context), + DeviceUtils.dpToPx(top, context), + DeviceUtils.dpToPx(right, context), + DeviceUtils.dpToPx(bottom, context) + ); + return layoutParams; + } + +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterDialogFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterDialogFragment.java new file mode 100644 index 0000000000..581af4ae5b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterDialogFragment.java @@ -0,0 +1,41 @@ +// Created by evermind-zz 2022, licensed GNU GPL version 3 or later + +package org.schabi.newpipe.fragments.list.search.filter; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import org.schabi.newpipe.databinding.SearchFilterDialogFragmentBinding; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.Toolbar; + +/** + * A search filter dialog that also looks like a dialog aka. 'dialog style'. + */ +public class SearchFilterDialogFragment extends BaseSearchFilterDialogFragment { + + protected SearchFilterDialogFragmentBinding binding; + + @Override + protected BaseSearchFilterUiGenerator createSearchFilterDialogGenerator() { + return new SearchFilterDialogGenerator( + searchViewModel.getSearchFilterLogic(), binding.verticalScroll, requireContext()); + } + + @Override + @Nullable + protected Toolbar getToolbar() { + return binding.toolbarLayout.toolbar; + } + + @Override + protected View getRootView(@NonNull final LayoutInflater inflater, + @Nullable final ViewGroup container) { + binding = SearchFilterDialogFragmentBinding + .inflate(inflater, container, false); + return binding.getRoot(); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterDialogGenerator.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterDialogGenerator.java new file mode 100644 index 0000000000..b1ee9c75fe --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterDialogGenerator.java @@ -0,0 +1,337 @@ +// Created by evermind-zz 2022, licensed GNU GPL version 3 or later + +package org.schabi.newpipe.fragments.list.search.filter; + +import android.content.Context; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.GridLayout; +import android.widget.Spinner; +import android.widget.TextView; + +import com.google.android.material.chip.Chip; +import com.google.android.material.chip.ChipGroup; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.extractor.search.filter.FilterGroup; +import org.schabi.newpipe.extractor.search.filter.FilterItem; +import org.schabi.newpipe.util.DeviceUtils; +import org.schabi.newpipe.util.ServiceHelper; + +import java.util.List; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public class SearchFilterDialogGenerator extends BaseSearchFilterUiDialogGenerator { + private static final int CHIP_GROUP_ELEMENTS_THRESHOLD = 2; + private static final int CHIP_MIN_TOUCH_TARGET_SIZE_DP = 40; + protected final GridLayout globalLayout; + + public SearchFilterDialogGenerator( + @NonNull final SearchFilterLogic logic, + @NonNull final ViewGroup root, + @NonNull final Context context) { + super(logic, context); + this.globalLayout = createGridLayout(); + root.addView(globalLayout); + } + + @Override + protected void createTitle(@NonNull final String name, + @NonNull final List titleViewElements) { + final TextView titleView = createTitleText(name); + final View separatorLine = createSeparatorLine(); + final View separatorLine2 = createSeparatorLine(); + + globalLayout.addView(separatorLine); + globalLayout.addView(titleView); + globalLayout.addView(separatorLine2); + + titleViewElements.add(titleView); + titleViewElements.add(separatorLine); + titleViewElements.add(separatorLine2); + } + + @Override + protected void createFilterGroup(@NonNull final FilterGroup filterGroup, + @NonNull final UiWrapperMapDelegate wrapperDelegate, + @NonNull final UiSelectorDelegate selectorDelegate) { + final GridLayout.LayoutParams layoutParams = getLayoutParamsViews(); + boolean doSpanDataOverMultipleCells = false; + final UiItemWrapperViews viewsWrapper = new UiItemWrapperViews( + filterGroup.getIdentifier()); + + final TextView filterLabel; + if (filterGroup.getNameId() != null) { + filterLabel = createFilterLabel(filterGroup, layoutParams); + viewsWrapper.add(filterLabel); + } else { + filterLabel = null; + doSpanDataOverMultipleCells = true; + } + + if (filterGroup.isOnlyOneCheckable()) { + if (filterLabel != null) { + globalLayout.addView(filterLabel); + } + + final Spinner filterDataSpinner = new Spinner(context, Spinner.MODE_DROPDOWN); + + final GridLayout.LayoutParams spinnerLp = + clipFreeRightColumnLayoutParams(doSpanDataOverMultipleCells); + setDefaultMargin(spinnerLp); + filterDataSpinner.setLayoutParams(spinnerLp); + setZeroPadding(filterDataSpinner); + + createUiElementsForSingleSelectableItemsFilterGroup( + filterGroup, wrapperDelegate, selectorDelegate, filterDataSpinner); + + viewsWrapper.add(filterDataSpinner); + globalLayout.addView(filterDataSpinner); + + } else { // multiple items in FilterGroup selectable + final ChipGroup chipGroup = new ChipGroup(context); + doSpanDataOverMultipleCells = chooseParentViewForFilterLabelAndAdd( + filterGroup, doSpanDataOverMultipleCells, filterLabel, chipGroup); + + viewsWrapper.add(chipGroup); + globalLayout.addView(chipGroup); + chipGroup.setLayoutParams( + clipFreeRightColumnLayoutParams(doSpanDataOverMultipleCells)); + chipGroup.setSingleLine(false); + + createUiChipElementsForFilterGroupItems( + filterGroup, wrapperDelegate, selectorDelegate, chipGroup); + } + + wrapperDelegate.put(filterGroup.getIdentifier(), viewsWrapper); + } + + @NonNull + protected TextView createFilterLabel(@NonNull final FilterGroup filterGroup, + @NonNull final GridLayout.LayoutParams layoutParams) { + final TextView filterLabel; + filterLabel = new TextView(context); + + filterLabel.setId(filterGroup.getIdentifier()); + filterLabel.setText( + ServiceHelper.getTranslatedFilterString(filterGroup.getNameId(), context)); + filterLabel.setGravity(Gravity.CENTER_VERTICAL); + setDefaultMargin(layoutParams); + setZeroPadding(filterLabel); + + filterLabel.setLayoutParams(layoutParams); + return filterLabel; + } + + private boolean chooseParentViewForFilterLabelAndAdd( + @NonNull final FilterGroup filterGroup, + final boolean doSpanDataOverMultipleCells, + @Nullable final TextView filterLabel, + @NonNull final ChipGroup possibleParentView) { + + boolean spanOverMultipleCells = doSpanDataOverMultipleCells; + if (filterLabel != null) { + // If we have more than CHIP_GROUP_ELEMENTS_THRESHOLD elements to be + // displayed as Chips add its filterLabel as first element to ChipGroup. + // Now the ChipGroup can be spanned over all the cells to use + // the space better. + if (filterGroup.getFilterItems().size() > CHIP_GROUP_ELEMENTS_THRESHOLD) { + possibleParentView.addView(filterLabel); + spanOverMultipleCells = true; + } else { + globalLayout.addView(filterLabel); + } + } + return spanOverMultipleCells; + } + + private void createUiElementsForSingleSelectableItemsFilterGroup( + @NonNull final FilterGroup filterGroup, + @NonNull final UiWrapperMapDelegate wrapperDelegate, + @NonNull final UiSelectorDelegate selectorDelegate, + @NonNull final Spinner filterDataSpinner) { + filterDataSpinner.setAdapter(new SearchFilterDialogSpinnerAdapter( + context, filterGroup, wrapperDelegate, filterDataSpinner)); + + final AdapterView.OnItemSelectedListener listener; + listener = new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(final AdapterView parent, final View view, + final int position, final long id) { + if (view != null) { + selectorDelegate.selectFilter(view.getId()); + } + } + + @Override + public void onNothingSelected(final AdapterView parent) { + // we are only interested onItemSelected() -> no implementation here + } + }; + + filterDataSpinner.setOnItemSelectedListener(listener); + } + + protected void createUiChipElementsForFilterGroupItems( + @NonNull final FilterGroup filterGroup, + @NonNull final UiWrapperMapDelegate wrapperDelegate, + @NonNull final UiSelectorDelegate selectorDelegate, + @NonNull final ChipGroup chipGroup) { + for (final FilterItem item : filterGroup.getFilterItems()) { + + if (item instanceof InjectFilterItem.DividerItem) { + final InjectFilterItem.DividerItem dividerItem = + (InjectFilterItem.DividerItem) item; + + // For the width MATCH_PARENT is necessary as this allows the + // dividerLabel to fill one row of ChipGroup exclusively + final ChipGroup.LayoutParams layoutParams = new ChipGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + final TextView dividerLabel = createDividerLabel(dividerItem, layoutParams); + chipGroup.addView(dividerLabel); + } else { + final Chip chip = createChipView(chipGroup, item); + + final View.OnClickListener listener; + listener = view -> selectorDelegate.selectFilter(view.getId()); + chip.setOnClickListener(listener); + + chipGroup.addView(chip); + wrapperDelegate.put(item.getIdentifier(), + new UiItemWrapperChip(item, chip, chipGroup)); + } + } + } + + @NonNull + private Chip createChipView(@NonNull final ChipGroup chipGroup, + @NonNull final FilterItem item) { + final Chip chip = (Chip) LayoutInflater.from(context).inflate( + R.layout.chip_search_filter, chipGroup, false); + chip.ensureAccessibleTouchTarget( + DeviceUtils.dpToPx(CHIP_MIN_TOUCH_TARGET_SIZE_DP, context)); + chip.setText(ServiceHelper.getTranslatedFilterString(item.getNameId(), context)); + chip.setId(item.getIdentifier()); + chip.setCheckable(true); + return chip; + } + + @NonNull + private TextView createDividerLabel( + @NonNull final InjectFilterItem.DividerItem dividerItem, + @NonNull final ViewGroup.MarginLayoutParams layoutParams) { + final TextView dividerLabel; + dividerLabel = new TextView(context); + dividerLabel.setEnabled(true); + + dividerLabel.setGravity(Gravity.CENTER_VERTICAL); + setDefaultMargin(layoutParams); + dividerLabel.setLayoutParams(layoutParams); + final String menuDividerTitle = + context.getString(dividerItem.getStringResId()); + dividerLabel.setText(menuDividerTitle); + return dividerLabel; + } + + @NonNull + protected SeparatorLineView createSeparatorLine() { + return createSeparatorLine(clipFreeRightColumnLayoutParams(true)); + } + + @NonNull + private TextView createTitleText(final String name) { + final TextView title = createTitleText(name, + clipFreeRightColumnLayoutParams(true)); + title.setGravity(Gravity.CENTER); + return title; + } + + @NonNull + private GridLayout createGridLayout() { + final GridLayout layout = new GridLayout(context); + + layout.setColumnCount(2); + + final GridLayout.LayoutParams layoutParams = new GridLayout.LayoutParams(); + layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT; + layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT; + setDefaultMargin(layoutParams); + layout.setLayoutParams(layoutParams); + + return layout; + } + + @NonNull + protected GridLayout.LayoutParams clipFreeRightColumnLayoutParams(final boolean doColumnSpan) { + final GridLayout.LayoutParams layoutParams = new GridLayout.LayoutParams(); + // https://stackoverflow.com/questions/37744672/gridlayout-children-are-being-clipped + layoutParams.width = 0; + layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT; + layoutParams.setGravity(Gravity.FILL_HORIZONTAL | Gravity.CENTER_VERTICAL); + setDefaultMargin(layoutParams); + + if (doColumnSpan) { + layoutParams.columnSpec = GridLayout.spec(0, 2, 1.0f); + } + + return layoutParams; + } + + @NonNull + private GridLayout.LayoutParams getLayoutParamsViews() { + final GridLayout.LayoutParams layoutParams = new GridLayout.LayoutParams(); + layoutParams.setGravity(Gravity.CENTER_VERTICAL); + setDefaultMargin(layoutParams); + return layoutParams; + } + + @NonNull + protected ViewGroup.MarginLayoutParams setDefaultMargin( + @NonNull final ViewGroup.MarginLayoutParams layoutParams) { + layoutParams.setMargins( + DeviceUtils.dpToPx(4, context), + DeviceUtils.dpToPx(2, context), + DeviceUtils.dpToPx(4, context), + DeviceUtils.dpToPx(2, context) + ); + return layoutParams; + } + + @NonNull + protected View setZeroPadding(@NonNull final View view) { + view.setPadding(0, 0, 0, 0); + return view; + } + + public static class UiItemWrapperChip extends BaseUiItemWrapper { + + @NonNull + private final ChipGroup chipGroup; + + public UiItemWrapperChip(@NonNull final FilterItem item, + @NonNull final View view, + @NonNull final ChipGroup chipGroup) { + super(item, view); + this.chipGroup = chipGroup; + } + + @Override + public boolean isChecked() { + return ((Chip) view).isChecked(); + } + + @Override + public void setChecked(final boolean checked) { + ((Chip) view).setChecked(checked); + + if (checked) { + chipGroup.check(view.getId()); + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterDialogSpinnerAdapter.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterDialogSpinnerAdapter.java new file mode 100644 index 0000000000..dd18dce78c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterDialogSpinnerAdapter.java @@ -0,0 +1,224 @@ +// Created by evermind-zz 2022, licensed GNU GPL version 3 or later + +package org.schabi.newpipe.fragments.list.search.filter; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.util.SparseIntArray; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.Spinner; +import android.widget.TextView; + +import org.schabi.newpipe.extractor.search.filter.FilterContainer; +import org.schabi.newpipe.extractor.search.filter.FilterGroup; +import org.schabi.newpipe.extractor.search.filter.FilterItem; +import org.schabi.newpipe.util.DeviceUtils; +import org.schabi.newpipe.util.ServiceHelper; + +import java.util.Objects; + +import androidx.annotation.NonNull; +import androidx.collection.SparseArrayCompat; + +import static org.schabi.newpipe.fragments.list.search.filter.InjectFilterItem.DividerItem; + +public class SearchFilterDialogSpinnerAdapter extends BaseAdapter { + + private final Context context; + private final FilterGroup group; + private final BaseSearchFilterUiGenerator.UiWrapperMapDelegate wrapperDelegate; + private final Spinner spinner; + private final SparseIntArray id2PosMap = new SparseIntArray(); + private final SparseArrayCompat + viewWrapperMap = new SparseArrayCompat<>(); + + public SearchFilterDialogSpinnerAdapter( + @NonNull final Context context, + @NonNull final FilterGroup group, + @NonNull final BaseSearchFilterUiGenerator.UiWrapperMapDelegate wrapperDelegate, + @NonNull final Spinner filterDataSpinner) { + this.context = context; + this.group = group; + this.wrapperDelegate = wrapperDelegate; + this.spinner = filterDataSpinner; + + createViewWrappers(); + } + + @Override + public int getCount() { + return group.getFilterItems().size(); + } + + @Override + public Object getItem(final int position) { + return group.getFilterItems().get(position); + } + + @Override + public long getItemId(final int position) { + return position; + } + + @Override + public View getView(final int position, final View convertView, final ViewGroup parent) { + final FilterItem item = group.getFilterItems().get(position); + final TextView view; + + if (convertView != null) { + view = (TextView) convertView; + } else { + view = createViewItem(); + } + + initViewWithData(position, item, view); + return view; + } + + @SuppressLint("WrongConstant") + private void initViewWithData(final int position, + final FilterItem item, + final TextView view) { + final UiItemWrapperSpinner wrappedView = + viewWrapperMap.get(position); + Objects.requireNonNull(wrappedView); + + view.setId(item.getIdentifier()); + view.setText(ServiceHelper.getTranslatedFilterString(item.getNameId(), context)); + view.setVisibility(wrappedView.getVisibility()); + view.setEnabled(wrappedView.isEnabled()); + + if (item instanceof DividerItem) { + final DividerItem dividerItem = (DividerItem) item; + wrappedView.setEnabled(false); + view.setEnabled(wrappedView.isEnabled()); + final String menuDividerTitle = ">>>" + + context.getString(dividerItem.getStringResId()) + "<<<"; + view.setText(menuDividerTitle); + } + } + + private void createViewWrappers() { + int position = 0; + for (final FilterItem item : this.group.getFilterItems()) { + final int initialVisibility = View.VISIBLE; + final boolean isInitialEnabled = true; + + final UiItemWrapperSpinner wrappedView = + new UiItemWrapperSpinner( + item, + initialVisibility, + isInitialEnabled, + spinner); + + if (item instanceof DividerItem) { + wrappedView.setEnabled(false); + } + + // store wrapper also locally as we refer here regularly + viewWrapperMap.put(position, wrappedView); + // store wrapper globally in SearchFilterLogic + wrapperDelegate.put(item.getIdentifier(), wrappedView); + id2PosMap.put(item.getIdentifier(), position); + position++; + } + } + + @NonNull + private TextView createViewItem() { + final TextView view = new TextView(context); + view.setLayoutParams(new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + view.setGravity(Gravity.CENTER_VERTICAL); + view.setPadding( + DeviceUtils.dpToPx(8, context), + DeviceUtils.dpToPx(4, context), + DeviceUtils.dpToPx(8, context), + DeviceUtils.dpToPx(4, context) + ); + return view; + } + + public int getItemPositionForFilterId(final int id) { + return id2PosMap.get(id); + } + + @Override + public boolean isEnabled(final int position) { + final UiItemWrapperSpinner wrappedView = + viewWrapperMap.get(position); + Objects.requireNonNull(wrappedView); + return wrappedView.isEnabled(); + } + + private static class UiItemWrapperSpinner + extends BaseItemWrapper { + @NonNull + private final Spinner spinner; + + /** + * We have to store the visibility of the view and if it is enabled. + *

+ * Reason: the Spinner adapter reuses {@link View} elements through the parameter + * convertView in {@link SearchFilterDialogSpinnerAdapter#getView(int, View, ViewGroup)} + * -> this is the Android Adapter's time saving characteristic to rather reuse + * than to recreate a {@link View}. + * -> so we reuse what Android gives us in above mentioned method. + */ + private int visibility; + private boolean enabled; + + UiItemWrapperSpinner(@NonNull final FilterItem item, + final int initialVisibility, + final boolean isInitialEnabled, + @NonNull final Spinner spinner) { + super(item); + this.spinner = spinner; + + this.visibility = initialVisibility; + this.enabled = isInitialEnabled; + } + + @Override + public void setVisible(final boolean visible) { + if (visible) { + visibility = View.VISIBLE; + } else { + visibility = View.GONE; + } + } + + @Override + public boolean isChecked() { + return spinner.getSelectedItem() == item; + } + + @Override + public void setChecked(final boolean checked) { + if (super.getItemId() != FilterContainer.ITEM_IDENTIFIER_UNKNOWN) { + final SearchFilterDialogSpinnerAdapter adapter = + (SearchFilterDialogSpinnerAdapter) spinner.getAdapter(); + spinner.setSelection(adapter.getItemPositionForFilterId(super.getItemId())); + } + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(final boolean enabled) { + this.enabled = enabled; + } + + public int getVisibility() { + return visibility; + } + + public void setVisibility(final int visibility) { + this.visibility = visibility; + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterLogic.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterLogic.java new file mode 100644 index 0000000000..ca7754e789 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterLogic.java @@ -0,0 +1,830 @@ +// Created by evermind-zz 2022, licensed GNU GPL version 3 or later + +package org.schabi.newpipe.fragments.list.search.filter; + +import org.schabi.newpipe.extractor.linkhandler.SearchQueryHandlerFactory; +import org.schabi.newpipe.extractor.search.filter.FilterContainer; +import org.schabi.newpipe.extractor.search.filter.FilterGroup; +import org.schabi.newpipe.extractor.search.filter.FilterItem; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.collection.SparseArrayCompat; + +import static org.schabi.newpipe.extractor.search.filter.FilterContainer.ITEM_IDENTIFIER_UNKNOWN; + +/** + * This class handles all the user interaction with the content and sort filters + * of NewPipeExtractor. + *

+ * It also facilitates the generation of the Ui's according to the implemented + * {@link ICreateUiForFiltersWorker}'s. + */ +public class SearchFilterLogic { + + /** + * This list is used to communicate with NewPipeExtractor. + * It contains only the content filter ids that the user has selected from the UI. + */ + private final List userSelectedContentFilters = new ArrayList<>(); + /** + * This list is used to communicate with NewPipeExtractor. + * It contains only the sort filter ids that the user has selected from the UI. + */ + private final List userSelectedSortFilters = new ArrayList<>(); + private final SearchQueryHandlerFactory searchQHFactory; + private final ExclusiveGroups contentFilterExclusive = new ExclusiveGroups(); + private final ExclusiveGroups sortFilterExclusive = new ExclusiveGroups(); + private final SparseArrayCompat contentFilterIdToUiItemMap = + new SparseArrayCompat<>(); + private final SparseArrayCompat sortFilterIdToUiItemMap = + new SparseArrayCompat<>(); + private final SparseArrayCompat contentFilterFidToSupersetSortFilterMap = + new SparseArrayCompat<>(); + private Callback callback; + /** + * This list is used to store via Icepick and eventual store as preset + * It contains all the content filter ids that the user has selected. It + * contains the same ids than {@link #userSelectedContentFilters} + */ + private List selectedContentFilters = new ArrayList<>(); + /** + * This list is used to store via Icepick and eventual store as preset + * It contains all the sort filter ids that the user has selected and also + * default id of none visible but selected sort filters. + * It is a superset to {@link #userSelectedContentFilters}. + */ + private List selectedSortFilters = new ArrayList<>(); + + /** + * Store a reference of the sort filters Ui creator. This is needed + * as a mechanism to tell if (the sort filter title) should be displayed or not. + *

+ * The work is done via {@link ICreateUiForFiltersWorker#filtersVisible(boolean)} + */ + private ICreateUiForFiltersWorker uiSortFilterWorker; + + + private SearchFilterLogic(@NonNull final SearchQueryHandlerFactory searchQHFactory, + @Nullable final Callback callback) { + this.searchQHFactory = searchQHFactory; + this.callback = callback; + initContentFilters(); + initSortFilters(); + } + + public void setCallback(@Nullable final Callback callback) { + this.callback = callback; + } + + public void reset() { + initContentFilters(); + initSortFilters(); + deselectUiItems(contentFilterIdToUiItemMap); + deselectUiItems(sortFilterIdToUiItemMap); + reselectUiItems(selectedContentFilters, contentFilterIdToUiItemMap); + reselectUiItems(selectedSortFilters, sortFilterIdToUiItemMap); + showSortFilterContainerUI(); + } + + private void reInitExclusiveFilterIds(@NonNull final List selectedFilters, + @NonNull final ExclusiveGroups exclusive) { + checkIfIdsAreValid(selectedFilters, exclusive); + + for (final int id : selectedFilters) { + exclusive.ifInExclusiveGroupRemovePreviouslySelectedId(id); + exclusive.addIdIfBelongsToExclusiveGroup(id); + } + } + + public void restorePreviouslySelectedFilters( + @Nullable final List selectedContentFilterList, + @Nullable final List selectedSortFilterList) { + if (selectedContentFilterList != null && selectedSortFilterList != null + && !selectedContentFilterList.isEmpty()) { + reInitExclusiveFilterIds(selectedContentFilterList, contentFilterExclusive); + reInitExclusiveFilterIds(selectedSortFilterList, sortFilterExclusive); + + this.selectedContentFilters = selectedContentFilterList; + this.selectedSortFilters = selectedSortFilterList; + } + + createContentFilterItemListFromIdentifierList(); + createSortFilterItemListFromIdentifiersList(); + } + + private void reselectUiItems( + @NonNull final List selectedFilters, + @NonNull final SparseArrayCompat filterIdToUiItemMap) { + for (final int id : selectedFilters) { + final IUiItemWrapper iUiItemWrapper = filterIdToUiItemMap.get(id); + if (iUiItemWrapper != null) { + iUiItemWrapper.setChecked(true); + } + } + } + + private void deselectUiItems( + @NonNull final SparseArrayCompat filterIdToUiItemMap) { + for (int index = 0; index < filterIdToUiItemMap.size(); index++) { + final IUiItemWrapper iUiItemWrapper = filterIdToUiItemMap.valueAt(index); + if (iUiItemWrapper != null) { + iUiItemWrapper.setChecked(false); + } + } + } + + // get copy of internal list + @NonNull + public ArrayList getSelectedContentFilters() { + return new ArrayList<>(this.selectedContentFilters); + } + + // get copy of internal list + @NonNull + public ArrayList getSelectedSortFilters() { + return new ArrayList<>(this.selectedSortFilters); + } + + // get copy of internal list, elements are not copied + @NonNull + public List getSelectedContentFilterItems() { + return new ArrayList<>(this.userSelectedContentFilters); + } + + // get copy of internal list, elements are not copied + @NonNull + public List getSelectedSortFiltersItems() { + return new ArrayList<>(this.userSelectedSortFilters); + } + + public void initContentFiltersUi( + @NonNull final ICreateUiForFiltersWorker createUiForFiltersWorker) { + final FilterContainer filters = searchQHFactory.getAvailableContentFilter(); + + if (filters != null && filters.getFilterGroups() != null) { + initFiltersUi(filters.getFilterGroups(), + contentFilterIdToUiItemMap, + createUiForFiltersWorker); + } + + reselectUiItems(selectedContentFilters, contentFilterIdToUiItemMap); + } + + public void initSortFiltersUi( + @NonNull final ICreateUiForFiltersWorker createUiForFiltersWorker) { + final FilterContainer filters = searchQHFactory.getAvailableContentFilter(); + final List sortGroups = getAllSortFilterGroups(filters); + uiSortFilterWorker = createUiForFiltersWorker; + + initFiltersUi(sortGroups, + sortFilterIdToUiItemMap, + createUiForFiltersWorker); + + reselectUiItems(selectedSortFilters, sortFilterIdToUiItemMap); + } + + /** + * Create Ui elements. + * + * @param filterGroups the filter groups that whom a UI should be created + * @param filterIdToUiItemMap points to a {@link FilterItem} or {@link FilterGroup} + * corresponding actual UI element(s). This map will be first + * called clear() on here. + * @param createUiForFiltersWorker the implementation how to create the UI. + */ + private void initFiltersUi( + @NonNull final List filterGroups, + @NonNull final SparseArrayCompat filterIdToUiItemMap, + @NonNull final ICreateUiForFiltersWorker createUiForFiltersWorker) { + + filterIdToUiItemMap.clear(); + Objects.requireNonNull(createUiForFiltersWorker); + createUiForFiltersWorker.prepare(); + for (final FilterGroup filterGroup : filterGroups) { + createUiForFiltersWorker.createFilterGroupBeforeItems(filterGroup); + for (final FilterItem filterItem : filterGroup.getFilterItems()) { + createUiForFiltersWorker.createFilterItem(filterItem, filterGroup); + } + createUiForFiltersWorker.createFilterGroupAfterItems(filterGroup); + } + createUiForFiltersWorker.finish(); + } + + /** + * Init the content filter logical states. + *

+ * - create list with default id that will be preselected + * - create exclusivity lists for exclusive groups + * {@link ExclusiveGroups#filterIdToGroupIdMap} and + * {@link ExclusiveGroups#exclusiveGroupsIdSet} + * - check if {@link #selectedContentFilters} are valid ids + * + * @param filterGroups content or sort filter {@link FilterGroup} array + * @param exclusive corresponding exclusive object (either for content + * or sort) filter array + * @param selectedFilters corresponding selected filter ids + * @param fidToSupersetSortFilterMap null possible, only for content filters relevant + */ + private void initFilters( + @NonNull final List filterGroups, + @NonNull final ExclusiveGroups exclusive, + @NonNull final List selectedFilters, + @Nullable final SparseArrayCompat fidToSupersetSortFilterMap) { + selectedFilters.clear(); + exclusive.clear(); + + for (final FilterGroup filterGroup : filterGroups) { + if (filterGroup.isOnlyOneCheckable()) { + exclusive.addGroupToExclusiveGroupsMap(filterGroup.getIdentifier()); + } + + // is the default selected filter for this group + final int defaultId = filterGroup.getDefaultSelectedFilterId(); + + for (final FilterItem item : filterGroup.getFilterItems()) { + if (fidToSupersetSortFilterMap != null) { + fidToSupersetSortFilterMap.put(item.getIdentifier(), + filterGroup.getAllSortFilters()); + } + exclusive.putFilterIdToItsGroupId(item.getIdentifier(), + filterGroup.getIdentifier()); + } + + if (defaultId != ITEM_IDENTIFIER_UNKNOWN) { + exclusive.handleIdInExclusiveGroup(defaultId, selectedFilters); + } + } + + checkIfIdsAreValid(selectedFilters, exclusive); + } + + private void checkIfIdsAreValid(@NonNull final List selectedFilters, + @NonNull final ExclusiveGroups exclusive) { + for (final int id : selectedFilters) { + if (!exclusive.filterIdToGroupIdMapContainsId(id)) { + throw new RuntimeException("The id " + id + " is invalid"); + } + } + } + + private void initContentFilters() { + final FilterContainer filters = searchQHFactory.getAvailableContentFilter(); + contentFilterFidToSupersetSortFilterMap.clear(); + + if (filters != null && filters.getFilterGroups() != null) { + initFilters(filters.getFilterGroups(), + contentFilterExclusive, selectedContentFilters, + contentFilterFidToSupersetSortFilterMap); + } + } + + private void initSortFilters() { + final FilterContainer filters = searchQHFactory.getAvailableContentFilter(); + final List sortGroups = getAllSortFilterGroups(filters); + initFilters(sortGroups, sortFilterExclusive, selectedSortFilters, null); + } + + /** + * Prepare content filter list with the actual {@link FilterItem}s to send to the library. + *

+ * The list is created through the {@link #userSelectedContentFilters} identifiers list. + * This identifiers refer to {@link FilterItem}s. + *

+ * {@link #userSelectedContentFilters} will be cleared first! + */ + private void createContentFilterItemListFromIdentifierList() { + userSelectedContentFilters.clear(); + final FilterContainer filterContainer = searchQHFactory.getAvailableContentFilter(); + + for (final int contentFilterId : selectedContentFilters) { + final FilterItem contentFilterItem = filterContainer.getFilterItem(contentFilterId); + if (contentFilterItem != null) { + userSelectedContentFilters.add(contentFilterItem); + } + } + } + + /** + * Prepare sort filter list with the actual {@link FilterItem}s to send to the library. + *

+ * The list is created through the {@link #userSelectedSortFilters} identifiers list. + * This identifiers refer to {@link FilterItem}s. + *

+ * {@link #userSelectedSortFilters} will be cleared first! + */ + private void createSortFilterItemListFromIdentifiersList() { + userSelectedSortFilters.clear(); + for (final int sortFilterId : selectedSortFilters) { + for (final int contentFilterId : selectedContentFilters) { + final FilterContainer filterContainer = + searchQHFactory.getContentFilterSortFilterVariant(contentFilterId); + if (filterContainer != null) { + final FilterItem sortFilterItem = filterContainer.getFilterItem(sortFilterId); + if (sortFilterItem != null) { + userSelectedSortFilters.add(sortFilterItem); + } + } + } + } + } + + public void showSortFilterContainerUI() { + showSortFilterIdsContainerUI(selectedContentFilters); + } + + /** + * Show only that sort filter UIs that are available for selected content ids. + * + * @param contentFilterIds content filter ids list + */ + private void showSortFilterIdsContainerUI(@NonNull final List contentFilterIds) { + for (final int contentFilterId : contentFilterIds) { + showSortFilterIdContainerUI(contentFilterId); + } + } + + private void notifySortFiltersVisibility() { + boolean sortFilterVisible = false; + if (uiSortFilterWorker != null) { + for (final int contentFilterId : selectedContentFilters) { + sortFilterVisible = searchQHFactory + .getContentFilterSortFilterVariant(contentFilterId) != null; + if (sortFilterVisible) { + break; + } + } + uiSortFilterWorker.filtersVisible(sortFilterVisible); + } + } + + /** + * Show only the sort filters that are available for a given content filter id. + * + * @param contentFilterId a content filter id and not a sort filter id. + */ + private void showSortFilterIdContainerUI(final int contentFilterId) { + final FilterContainer subsetFilterContainer = + searchQHFactory.getContentFilterSortFilterVariant(contentFilterId); + + final FilterContainer supersetFilterContainer = + contentFilterFidToSupersetSortFilterMap.get(contentFilterId); + if (subsetFilterContainer != null) { + if (supersetFilterContainer == null) { + throw new RuntimeException( + "supersetFilterContainer should never be null here"); + } + + setUiItemsVisibility(supersetFilterContainer, false, sortFilterIdToUiItemMap); + setUiItemsVisibility(subsetFilterContainer, true, sortFilterIdToUiItemMap); + } else { + if (supersetFilterContainer != null) { + setUiItemsVisibility(supersetFilterContainer, false, + sortFilterIdToUiItemMap); + } + } + notifySortFiltersVisibility(); + } + + /** + * This method is only used to show the all sort filters for measurement of the width. + *

+ * See {@link SearchFilterOptionMenuAlikeDialogGenerator} + */ + protected void showAllAvailableSortFilters() { + for (int index = 0; index < contentFilterFidToSupersetSortFilterMap.size(); index++) { + final FilterContainer container = + contentFilterFidToSupersetSortFilterMap.valueAt(index); + if (container != null) { + setUiItemsVisibility(container, true, sortFilterIdToUiItemMap); + } + } + } + + private void setUiItemsVisibility( + @Nullable final FilterContainer filters, + final boolean isVisible, + @NonNull final SparseArrayCompat filterIdToUiItemMap) { + if (filters != null && filters.getFilterGroups() != null) { + for (final FilterGroup filterGroup : filters.getFilterGroups()) { + setUiItemVisible(isVisible, filterIdToUiItemMap, filterGroup.getIdentifier()); + for (final FilterItem item : filterGroup.getFilterItems()) { + setUiItemVisible(isVisible, filterIdToUiItemMap, item.getIdentifier()); + } + } + } + } + + private void setUiItemVisible( + final boolean isVisible, + @NonNull final SparseArrayCompat filterIdToUiItemMap, + final int id) { + final IUiItemWrapper uiWrapper = filterIdToUiItemMap.get(id); + if (uiWrapper != null) { + uiWrapper.setVisible(isVisible); + } + } + + /** + * Get all sort filter groups for the content filters. + * It has to have all content filter groups that are available for a service. + * + * @param filters the content filters + * @return the sort filter groups. Empty list if either param filters or no + * filter groups available + */ + @NonNull + private List getAllSortFilterGroups(@Nullable final FilterContainer filters) { + if (filters != null && filters.getFilterGroups() != null) { + final List sortGroups = new ArrayList<>(); + for (final FilterGroup filterGroup : filters.getFilterGroups()) { + final FilterContainer sf = filterGroup.getAllSortFilters(); + if (sf != null && sf.getFilterGroups() != null) { + sortGroups.addAll(sf.getFilterGroups()); + } + } + return sortGroups; + } + return Collections.emptyList(); + } + + protected void handleIdInNonExclusiveGroup(final int filterId, + @Nullable final IUiItemWrapper uiItemWrapper, + @NonNull final List selectedFilter) { + if (uiItemWrapper != null) { // could be null if there is no UI + if (uiItemWrapper.isChecked()) { + if (!selectedFilter.contains(filterId)) { + selectedFilter.add(filterId); + } + } else { // remove from list + if (selectedFilter.contains(filterId)) { + selectedFilter.remove((Integer) filterId); + } + } + } else { // we have no UI + if (!selectedFilter.contains(filterId)) { + selectedFilter.add(filterId); + } else { + selectedFilter.remove((Integer) filterId); + } + } + } + + public synchronized void selectContentFilter(final int filterId) { + selectFilter(filterId, contentFilterIdToUiItemMap, selectedContentFilters, + contentFilterExclusive); + showSortFilterIdContainerUI(filterId); + } + + public synchronized void selectSortFilter(final int filterId) { + selectFilter(filterId, sortFilterIdToUiItemMap, selectedSortFilters, sortFilterExclusive); + } + + private void selectFilter( + final int id, + @NonNull final SparseArrayCompat filterIdToUiItemMap, + @NonNull final List selectedFilter, + @NonNull final ExclusiveGroups exclusive) { + final IUiItemWrapper uiItemWrapper = + filterIdToUiItemMap.get(id); + + // here we remove/add the by the UI (de)selected id. + if (exclusive.handleIdInExclusiveGroup(id, selectedFilter)) { + if (uiItemWrapper != null && !uiItemWrapper.isChecked()) { + uiItemWrapper.setChecked(true); + } + } else { + handleIdInNonExclusiveGroup(id, uiItemWrapper, selectedFilter); + } + } + + /** + * Prepare the content and sort filters {@link FilterItem}'s lists for a now filtered + * search. + *

+ * If a callback is registered it wil be called with copy's of the local sort and + * content lists. To avoid concurrently modification of the lists. As they are progressed + * through async javarx calls. Note: The members aka {@link FilterItem}'s are not copied. + */ + public void prepareForSearch() { + createContentFilterItemListFromIdentifierList(); + createSortFilterItemListFromIdentifiersList(); + + if (callback != null) { + callback.selectedFilters(new ArrayList<>(userSelectedContentFilters), + new ArrayList<>(userSelectedSortFilters)); + } + } + + /** + * This method is meant to be called to add {@link android.view.View}s that represents + * a content filter. + *

+ * It has to be called within a subclass of {@link SearchFilterLogic} which implements + * {@link ICreateUiForFiltersWorker} itself or as an any inner class. + * + * @param id the id of a content filter + * @param uiItemWrapper the wrapped UI {@link android.view.View} for that content filter + */ + public void addContentFilterUiWrapperToItemMap( + final int id, + @NonNull final IUiItemWrapper uiItemWrapper) { + contentFilterIdToUiItemMap.put(id, uiItemWrapper); + } + + /** + * This method is meant to be called to add {@link android.view.View}s that represents + * a sort filter. + *

+ * It has to be called within a subclass of {@link SearchFilterLogic} which implements + * {@link ICreateUiForFiltersWorker} itself or as an any inner class. + * + * @param id the id of a sort filter + * @param uiItemWrapper the wrapped UI {@link android.view.View} for that sort filter + */ + public void addSortFilterUiWrapperToItemMap( + final int id, + @NonNull final IUiItemWrapper uiItemWrapper) { + sortFilterIdToUiItemMap.put(id, uiItemWrapper); + } + + /** + * Wrap a {@link FilterItem} or {@link FilterGroup} to their + * actual UI element(s) ({@link android.view.View}). + */ + public interface IUiItemWrapper { + /** + * set a view element visible. + * + * @param visible true if visible, false if not visible + */ + void setVisible(boolean visible); + + /** + * @return get the id of the corresponding {@link FilterItem} + */ + int getItemId(); + + /** + * Is the UI element selected. + * + * @return true if selected + */ + boolean isChecked(); + + /** + * select the UI element. + * + * @param checked select UI element + */ + void setChecked(boolean checked); + } + + /** + * Creating user elements for all filters inside a {@link FilterContainer}. + * + * Note: use {@link #addContentFilterUiWrapperToItemMap(int, IUiItemWrapper)} and + * {@link #addSortFilterUiWrapperToItemMap(int, IUiItemWrapper)} to actually make + * {@link SearchFilterLogic} aware of them. + */ + public interface ICreateUiForFiltersWorker { + /** + * Will be called before any {@link FilterContainer} looping. + */ + void prepare(); + + /** + * Create Ui elements specifically related to the {@link FilterGroup} itself. + * But it could also be used for creating items. + *

+ * -> This method is called *before* the {@link #createFilterItem(FilterItem, FilterGroup)} + * + * @param filterGroup one group each time from {@link FilterContainer#getFilterGroups()} + */ + void createFilterGroupBeforeItems(@NonNull FilterGroup filterGroup); + + /** + * Create Ui elements specifically related to a {@link FilterItem} itself. + * + * @param filterItem the actual item you should create a UI element here + * @param filterGroup (optional) one group each time from + * {@link FilterContainer#getFilterGroups()} + */ + void createFilterItem(@NonNull FilterItem filterItem, @NonNull FilterGroup filterGroup); + + /** + * Create Ui elements specifically related to the {@link FilterGroup} itself. + * But it could also be used for creating items. + *

+ * -> This method is called *after* the {@link #createFilterItem(FilterItem, FilterGroup)} + * + * @param filterGroup one group each time from {@link FilterContainer#getFilterGroups()} + */ + void createFilterGroupAfterItems(@NonNull FilterGroup filterGroup); + + /** + * do anything you might want to clean up or whatever. + */ + void finish(); + + /** + * Notify if filters are visible. Eg to show or hide 'sort filter' section title + * + * @param areFiltersVisible true if filter visible + */ + void filtersVisible(boolean areFiltersVisible); + } + + /** + * This callback will be called if a search with additional filters should occur. + */ + public interface Callback { + void selectedFilters(@NonNull List userSelectedContentFilter, + @NonNull List userSelectedSortFilter); + } + + /** + * Track and handle filters of groups in which only one {@link FilterItem} can be selected. + *

+ * We need to track this ourselves as we otherwise rely on androids functionality or lack of + * tracking the before selected item that now is unselected. + */ + private static class ExclusiveGroups { + + final SparseArrayCompat actualSelectedFilterIdInExclusiveGroupMap = + new SparseArrayCompat<>(); + /** + * To quickly determine if a content filter group supports + * only one item selected (exclusiveness), we need a set that resembles that. + */ + private final Set exclusiveGroupsIdSet = new HashSet<>(); + /** + * To quickly determine if a content filter id belongs to an exclusive group. + * This maps works in conjunction with {@link #exclusiveGroupsIdSet} + */ + private final SparseArrayCompat filterIdToGroupIdMap = + new SparseArrayCompat<>(); + + /** + * Clear {@link #exclusiveGroupsIdSet} and {@link #filterIdToGroupIdMap}. + */ + public void clear() { + exclusiveGroupsIdSet.clear(); + filterIdToGroupIdMap.clear(); + actualSelectedFilterIdInExclusiveGroupMap.clear(); + } + + /** + * Check if filter id is valid. + * + * @param filterId the filter id to check + * @return true if valid + */ + public boolean filterIdToGroupIdMapContainsId(final int filterId) { + return filterIdToGroupIdMap.indexOfKey(filterId) >= 0; + } + + public boolean isFilterIdPartOfAnExclusiveGroup(final int filterId) { + if (filterIdToGroupIdMapContainsId(filterId)) { + final int filterGroupId = + Objects.requireNonNull(filterIdToGroupIdMap.get(filterId)); + return exclusiveGroupsIdSet.contains(filterGroupId); + } + return false; + } + + /** + * @param filterId the id of a {@link FilterItem} + * @param selectedFilter the list of filter Ids that could contain the given id + * @return true if exclusive group + */ + private boolean handleIdInExclusiveGroup(final int filterId, + @NonNull final List selectedFilter) { + // case exclusive group selection + if (isFilterIdPartOfAnExclusiveGroup(filterId)) { + final int previousSelectedId = + ifInExclusiveGroupRemovePreviouslySelectedId(filterId); + if (selectedFilter.contains(previousSelectedId)) { + selectedFilter.remove((Integer) previousSelectedId); + selectedFilter.add(filterId); + } else if (previousSelectedId == ITEM_IDENTIFIER_UNKNOWN) { + selectedFilter.add(filterId); + } + addIdIfBelongsToExclusiveGroup(filterId); + return true; + } + return false; + } + + /** + * Insert filter ids with corresponding group ids. + *

+ * We need to know which filter belongs to which group, that we can + * determine if a selected {@link FilterItem} is part of an exclusive + * group or not. + * + * @param filterId filter identifier + * @param filterGroupId group identifier + */ + public void putFilterIdToItsGroupId(final int filterId, final int filterGroupId) { + filterIdToGroupIdMap.put(filterId, filterGroupId); + } + + /** + * Add exclusive groups to the map. + * + * @param groupId the id of the exclusive group + */ + public void addGroupToExclusiveGroupsMap(final int groupId) { + exclusiveGroupsIdSet.add(groupId); + } + + private void addIdIfBelongsToExclusiveGroup(final int filterId) { + final int filterGroupId = + Objects.requireNonNull(filterIdToGroupIdMap.get(filterId)); + if (exclusiveGroupsIdSet.contains(filterGroupId)) { + actualSelectedFilterIdInExclusiveGroupMap.put(filterGroupId, filterId); + } + } + + /** + * check if the filter group id for a given filter id is already in a exclusive group. + *

+ * If so remove the group filter id. + * + * @param filterId the id of a filter that might belong to an exclusive filter group + * @return id of removed filter id from {@link #actualSelectedFilterIdInExclusiveGroupMap} + * otherwise {@link FilterContainer#ITEM_IDENTIFIER_UNKNOWN} + */ + + private int ifInExclusiveGroupRemovePreviouslySelectedId(final int filterId) { + int previousFilterId = ITEM_IDENTIFIER_UNKNOWN; + final int filterGroupId = + Objects.requireNonNull(filterIdToGroupIdMap.get(filterId)); + + final int index = actualSelectedFilterIdInExclusiveGroupMap.indexOfKey(filterGroupId); + if (exclusiveGroupsIdSet.contains(filterGroupId) && index >= 0) { + previousFilterId = actualSelectedFilterIdInExclusiveGroupMap.valueAt(index); + actualSelectedFilterIdInExclusiveGroupMap.removeAt(index); + } + return previousFilterId; + } + } + + public static final class Factory { + private Factory() { + } + + /** + * Create variant of {@link SearchFilterLogic}. + * + * @param logicVariant the variant {@link Variant}. + * @param searchQHFactory of the service + * @param callback if you want to get the data the user has requested by calling + * {@link SearchFilterLogic#prepareForSearch()} + * @return instance of {@link SearchFilterLogic}. + */ + @NonNull + public static SearchFilterLogic create( + @NonNull final Variant logicVariant, + @NonNull final SearchQueryHandlerFactory searchQHFactory, + @Nullable final Callback callback) { + switch (logicVariant) { + + case SEARCH_FILTER_LOGIC_LEGACY: // the case we are using SearchFragmentLegacy + return new SearchFilterLogic(searchQHFactory, callback) { + @Override + protected void handleIdInNonExclusiveGroup( + final int filterId, + @Nullable final IUiItemWrapper uiItemWrapper, + @NonNull final List selectedFilter) { + + if (null != uiItemWrapper) { + // for the action menu based UI we have to toggle first + // to be compatible with the SearchFilterLogic + uiItemWrapper.setChecked(!uiItemWrapper.isChecked()); + } + super.handleIdInNonExclusiveGroup( + filterId, uiItemWrapper, selectedFilter); + } + }; + + default: + case SEARCH_FILTER_LOGIC_DEFAULT: + return new SearchFilterLogic(searchQHFactory, callback); + } + } + + public enum Variant { + SEARCH_FILTER_LOGIC_DEFAULT, + SEARCH_FILTER_LOGIC_LEGACY + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterOptionMenuAlikeDialogFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterOptionMenuAlikeDialogFragment.java new file mode 100644 index 0000000000..b244cf7250 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterOptionMenuAlikeDialogFragment.java @@ -0,0 +1,77 @@ +// Created by evermind-zz 2022, licensed GNU GPL version 3 or later + +package org.schabi.newpipe.fragments.list.search.filter; + +import android.app.Dialog; +import android.os.Bundle; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.view.WindowManager; + +import org.schabi.newpipe.databinding.SearchFilterOptionMenuAlikeDialogFragmentBinding; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.Toolbar; + +/** + * A search filter dialog that looks like a action menu aka. 'action menu style'. + */ +public class SearchFilterOptionMenuAlikeDialogFragment extends BaseSearchFilterDialogFragment { + + private SearchFilterOptionMenuAlikeDialogFragmentBinding binding; + + @Override + protected BaseSearchFilterUiGenerator createSearchFilterDialogGenerator() { + return new SearchFilterOptionMenuAlikeDialogGenerator( + searchViewModel.getSearchFilterLogic(), binding.verticalScroll, requireContext()); + } + + @Override + @Nullable + protected Toolbar getToolbar() { + return binding.toolbarLayout.toolbar; + } + + @Override + protected View getRootView(@NonNull final LayoutInflater inflater, + final ViewGroup container) { + binding = SearchFilterOptionMenuAlikeDialogFragmentBinding + .inflate(inflater, container, false); + return binding.getRoot(); + } + + @Override + public void onViewCreated(@NonNull final View view, final Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + // place the dialog in the 'action menu position' + setDialogGravity(Gravity.END | Gravity.TOP); + } + + private void setDialogGravity(final int gravity) { + final Dialog dialog = getDialog(); + if (dialog != null) { + final Window window = dialog.getWindow(); + if (window != null) { + final WindowManager.LayoutParams layoutParams = window.getAttributes(); + layoutParams.width = ViewGroup.LayoutParams.WRAP_CONTENT; + layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT; + layoutParams.horizontalMargin = 0; + layoutParams.gravity = gravity; + layoutParams.dimAmount = 0; + layoutParams.flags &= ~WindowManager.LayoutParams.FLAG_DIM_BEHIND; + window.setAttributes(layoutParams); + } + } + } + + @Override + protected void initToolbar(final @NonNull Toolbar toolbar) { + super.initToolbar(toolbar); + // no room for a title + toolbar.setTitle(""); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterOptionMenuAlikeDialogGenerator.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterOptionMenuAlikeDialogGenerator.java new file mode 100644 index 0000000000..adc98a5f32 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterOptionMenuAlikeDialogGenerator.java @@ -0,0 +1,365 @@ +// Created by evermind-zz 2022, licensed GNU GPL version 3 or later + +package org.schabi.newpipe.fragments.list.search.filter; + +import android.content.Context; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CheckBox; +import android.widget.LinearLayout; +import android.widget.RadioButton; +import android.widget.RadioGroup; +import android.widget.TextView; + +import org.schabi.newpipe.extractor.search.filter.FilterGroup; +import org.schabi.newpipe.extractor.search.filter.FilterItem; +import org.schabi.newpipe.util.DeviceUtils; +import org.schabi.newpipe.util.ServiceHelper; + +import java.util.List; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import static android.util.TypedValue.COMPLEX_UNIT_DIP; +import static org.schabi.newpipe.fragments.list.search.filter.InjectFilterItem.DividerItem; + +public class SearchFilterOptionMenuAlikeDialogGenerator extends BaseSearchFilterUiDialogGenerator { + private static final Integer NO_RESIZE_VIEW_TAG = 1; + private static final float FONT_SIZE_SELECTABLE_VIEW_ITEMS_IN_DIP = 18f; + private static final int VIEW_ITEMS_MIN_WIDTH_IN_DIP = 168; + private final LinearLayout globalLayout; + + public SearchFilterOptionMenuAlikeDialogGenerator( + @NonNull final SearchFilterLogic logic, + @NonNull final ViewGroup root, + @NonNull final Context context) { + super(logic, context); + this.globalLayout = createLinearLayout(); + root.addView(globalLayout); + } + + @Override + protected void doMeasurementsIfNeeded() { + measureWidthOfChildrenAndResizeToWidest(); + } + + /** + * Resize all width of {@link #globalLayout} children without tag {@link #NO_RESIZE_VIEW_TAG}. + *

+ * Initially this method was only used to resize the width of separator line + * views created by {@link #createSeparatorLine()}. But now also the views + * the user will interact with are set to the widest child. + *

+ * Reasons: + * 1. Separator lines should be as wide as the widest UI element but this + * can only be determined on runtime + * 2. Other view elements more specific checkable/selectable should also + * expand their width over the complete dialog width to be easier to select + */ + private void measureWidthOfChildrenAndResizeToWidest() { + logic.showAllAvailableSortFilters(); + + // initialize width with a passable default width + int widestViewInPx = DeviceUtils.dpToPx(VIEW_ITEMS_MIN_WIDTH_IN_DIP, context); + final int noOfChildren = globalLayout.getChildCount(); + + for (int x = 0; x < noOfChildren; x++) { + final View childView = globalLayout.getChildAt(x); + childView.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED); + final int width = childView.getMeasuredWidth(); + if (width > widestViewInPx) { + widestViewInPx = width; + } + } + + for (int x = 0; x < noOfChildren; x++) { + final View childView = globalLayout.getChildAt(x); + + if (childView.getTag() != NO_RESIZE_VIEW_TAG) { + final ViewGroup.LayoutParams layoutParams = childView.getLayoutParams(); + layoutParams.width = widestViewInPx; + childView.setLayoutParams(layoutParams); + } + } + } + + @Override + protected void createTitle(@NonNull final String name, + @NonNull final List titleViewElements) { + final TextView titleView = createTitleText(name); + titleView.setTag(NO_RESIZE_VIEW_TAG); + final View separatorLine = createSeparatorLine(); + final View separatorLine2 = createSeparatorLine(); + final View separatorLine3 = createSeparatorLine(); + + globalLayout.addView(separatorLine); + globalLayout.addView(separatorLine2); + globalLayout.addView(titleView); + globalLayout.addView(separatorLine3); + + titleViewElements.add(titleView); + titleViewElements.add(separatorLine); + titleViewElements.add(separatorLine2); + titleViewElements.add(separatorLine3); + } + + @Override + protected void createFilterGroup(@NonNull final FilterGroup filterGroup, + @NonNull final UiWrapperMapDelegate wrapperDelegate, + @NonNull final UiSelectorDelegate selectorDelegate) { + final UiItemWrapperViews viewsWrapper = new UiItemWrapperViews( + filterGroup.getIdentifier()); + + final View separatorLine = createSeparatorLine(); + globalLayout.addView(separatorLine); + viewsWrapper.add(separatorLine); + + if (filterGroup.getNameId() != null) { + final TextView filterLabel = + createFilterGroupLabel(filterGroup, getLayoutParamsLabelLeft()); + globalLayout.addView(filterLabel); + viewsWrapper.add(filterLabel); + } + + if (filterGroup.isOnlyOneCheckable()) { + + final RadioGroup radioGroup = new RadioGroup(context); + radioGroup.setLayoutParams(getLayoutParamsViews()); + + createUiElementsForSingleSelectableItemsFilterGroup( + filterGroup, wrapperDelegate, selectorDelegate, radioGroup); + + globalLayout.addView(radioGroup); + viewsWrapper.add(radioGroup); + + } else { // multiple items in FilterGroup selectable + createUiElementsForMultipleSelectableItemsFilterGroup( + filterGroup, wrapperDelegate, selectorDelegate); + } + + wrapperDelegate.put(filterGroup.getIdentifier(), viewsWrapper); + } + + private void createUiElementsForSingleSelectableItemsFilterGroup( + @NonNull final FilterGroup filterGroup, + @NonNull final UiWrapperMapDelegate wrapperDelegate, + @NonNull final UiSelectorDelegate selectorDelegate, + @NonNull final RadioGroup radioGroup) { + for (final FilterItem item : filterGroup.getFilterItems()) { + + final View view; + if (item instanceof DividerItem) { + view = createDividerTextView(item, getLayoutParamsViews()); + } else { + view = createViewItemRadio(item, getLayoutParamsViews()); + + wrapperDelegate.put(item.getIdentifier(), + new UiItemWrapperCheckBoxAndRadioButton( + item, view, radioGroup)); + + final View.OnClickListener listener = v -> { + if (v != null) { + selectorDelegate.selectFilter(v.getId()); + } + }; + view.setOnClickListener(listener); + } + radioGroup.addView(view); + } + } + + private void createUiElementsForMultipleSelectableItemsFilterGroup( + @NonNull final FilterGroup filterGroup, + @NonNull final UiWrapperMapDelegate wrapperDelegate, + @NonNull final UiSelectorDelegate selectorDelegate) { + for (final FilterItem item : filterGroup.getFilterItems()) { + final View view; + if (item instanceof DividerItem) { + view = createDividerTextView(item, getLayoutParamsViews()); + } else { + final CheckBox checkBox = createCheckBox(item, getLayoutParamsViews()); + + wrapperDelegate.put(item.getIdentifier(), + new UiItemWrapperCheckBoxAndRadioButton( + item, checkBox, null)); + + final View.OnClickListener listener = v -> { + if (v != null) { + selectorDelegate.selectFilter(v.getId()); + } + }; + checkBox.setOnClickListener(listener); + + view = checkBox; + } + globalLayout.addView(view); + } + } + + @NonNull + private LinearLayout createLinearLayout() { + final LinearLayout linearLayout = new LinearLayout(context); + + linearLayout.setOrientation(LinearLayout.VERTICAL); + + final LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams(1, 1); + layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT; + layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT; + layoutParams.setMargins( + DeviceUtils.dpToPx(2, context), + DeviceUtils.dpToPx(2, context), + DeviceUtils.dpToPx(2, context), + DeviceUtils.dpToPx(2, context)); + linearLayout.setLayoutParams(layoutParams); + + return linearLayout; + } + + @NonNull + private LinearLayout.LayoutParams getLayoutForSeparatorLine() { + final LinearLayout.LayoutParams layoutParams = getLayoutParamsLabelLeft(); + layoutParams.width = 0; + layoutParams.gravity = Gravity.CENTER_HORIZONTAL; + return layoutParams; + } + + @NonNull + private View createSeparatorLine() { + return createSeparatorLine(getLayoutForSeparatorLine()); + } + + @NonNull + private TextView createTitleText(final String name) { + final LinearLayout.LayoutParams layoutParams = getLayoutParamsLabelLeft(); + layoutParams.gravity = Gravity.CENTER_HORIZONTAL; + final TextView title = createTitleText(name, layoutParams); + setPadding(title, 5); + return title; + } + + @NonNull + private View setPadding(@NonNull final View view, final int sizeInDip) { + final int sizeInPx = DeviceUtils.dpToPx(sizeInDip, context); + view.setPadding( + sizeInPx, + sizeInPx, + sizeInPx, + sizeInPx); + return view; + } + + @NonNull + private TextView createFilterGroupLabel(@NonNull final FilterGroup filterGroup, + @NonNull final ViewGroup.LayoutParams layoutParams) { + final TextView filterLabel = new TextView(context); + filterLabel.setId(filterGroup.getIdentifier()); + filterLabel.setText(ServiceHelper + .getTranslatedFilterString(filterGroup.getNameId(), context)); + filterLabel.setGravity(Gravity.TOP); + // resizing not needed as view is not selectable + filterLabel.setTag(NO_RESIZE_VIEW_TAG); + filterLabel.setLayoutParams(layoutParams); + return filterLabel; + } + + @NonNull + private CheckBox createCheckBox(@NonNull final FilterItem item, + @NonNull final ViewGroup.LayoutParams layoutParams) { + final CheckBox checkBox = new CheckBox(context); + checkBox.setLayoutParams(layoutParams); + checkBox.setText(ServiceHelper.getTranslatedFilterString( + item.getNameId(), context)); + checkBox.setId(item.getIdentifier()); + checkBox.setTextSize(COMPLEX_UNIT_DIP, FONT_SIZE_SELECTABLE_VIEW_ITEMS_IN_DIP); + return checkBox; + } + + @NonNull + private TextView createDividerTextView(@NonNull final FilterItem item, + @NonNull final ViewGroup.LayoutParams layoutParams) { + final DividerItem dividerItem = (DividerItem) item; + final TextView view = new TextView(context); + view.setEnabled(true); + final String menuDividerTitle = + context.getString(dividerItem.getStringResId()); + view.setText(menuDividerTitle); + view.setGravity(Gravity.TOP); + view.setLayoutParams(layoutParams); + return view; + } + + @NonNull + private RadioButton createViewItemRadio(@NonNull final FilterItem item, + @NonNull final ViewGroup.LayoutParams layoutParams) { + final RadioButton view = new RadioButton(context); + view.setId(item.getIdentifier()); + view.setText(ServiceHelper.getTranslatedFilterString(item.getNameId(), context)); + view.setLayoutParams(layoutParams); + view.setTextSize(COMPLEX_UNIT_DIP, FONT_SIZE_SELECTABLE_VIEW_ITEMS_IN_DIP); + return view; + } + + @NonNull + private LinearLayout.LayoutParams getLayoutParamsViews() { + final LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT); + layoutParams.setMargins( + DeviceUtils.dpToPx(4, context), + DeviceUtils.dpToPx(8, context), + DeviceUtils.dpToPx(4, context), + DeviceUtils.dpToPx(8, context)); + return layoutParams; + } + + @NonNull + private LinearLayout.LayoutParams getLayoutParamsLabelLeft() { + final LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT); + layoutParams.setMargins( + DeviceUtils.dpToPx(2, context), + DeviceUtils.dpToPx(2, context), + DeviceUtils.dpToPx(2, context), + DeviceUtils.dpToPx(2, context)); + return layoutParams; + } + + private static final class UiItemWrapperCheckBoxAndRadioButton + extends BaseUiItemWrapper { + + @Nullable + private final View group; + + private UiItemWrapperCheckBoxAndRadioButton(@NonNull final FilterItem item, + @NonNull final View view, + @Nullable final View group) { + super(item, view); + this.group = group; + } + + @Override + public boolean isChecked() { + if (view instanceof RadioButton) { + return ((RadioButton) view).isChecked(); + } else if (view instanceof CheckBox) { + return ((CheckBox) view).isChecked(); + } else { + return view.isSelected(); + } + } + + @Override + public void setChecked(final boolean checked) { + if (checked && group instanceof RadioGroup) { + ((RadioGroup) group).check(view.getId()); + } else if (view instanceof CheckBox) { + ((CheckBox) view).setChecked(checked); + } else { + view.setSelected(checked); + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterUIOptionMenu.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterUIOptionMenu.java new file mode 100644 index 0000000000..605c15dafe --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterUIOptionMenu.java @@ -0,0 +1,303 @@ +// Created by evermind-zz 2022, licensed GNU GPL version 3 or later + +package org.schabi.newpipe.fragments.list.search.filter; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.util.Log; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.extractor.search.filter.FilterContainer; +import org.schabi.newpipe.extractor.search.filter.FilterGroup; +import org.schabi.newpipe.extractor.search.filter.FilterItem; +import org.schabi.newpipe.extractor.search.filter.LibraryStringIds; +import org.schabi.newpipe.util.ServiceHelper; + +import java.util.ArrayList; +import java.util.List; + +import androidx.annotation.NonNull; +import androidx.appcompat.view.menu.MenuBuilder; +import androidx.core.view.MenuCompat; + +import static android.content.ContentValues.TAG; +import static org.schabi.newpipe.fragments.list.search.filter.InjectFilterItem.DividerItem; +import static org.schabi.newpipe.fragments.list.search.filter.SearchFilterLogic.ICreateUiForFiltersWorker; +import static org.schabi.newpipe.fragments.list.search.filter.SearchFilterLogic.IUiItemWrapper; + +/** + * The implementation of the action menu based 'dialog'. + */ +public class SearchFilterUIOptionMenu extends BaseSearchFilterUiGenerator { + + // Menu groups identifier + private static final int MENU_GROUP_SEARCH_RESET_BUTTONS = 0; + // give them negative ids to not conflict with the ids of the filters + private static final int MENU_ID_SEARCH_BUTTON = -100; + private static final int MENU_ID_RESET_BUTTON = -101; + private Menu menu = null; + // initialize with first group id -> next group after the search/reset buttons group + private int newLastUsedGroupId = MENU_GROUP_SEARCH_RESET_BUTTONS + 1; + private int firstSortFilterGroupId; + + public SearchFilterUIOptionMenu( + @NonNull final SearchFilterLogic logic, + @NonNull final Context context) { + super(logic, context); + } + + int getLastUsedGroupIdThanIncrement() { + return newLastUsedGroupId++; + } + + @SuppressLint("RestrictedApi") + private void alwaysShowMenuItemIcon(final Menu theMenu) { + // always show icons + if (theMenu instanceof MenuBuilder) { + final MenuBuilder builder = ((MenuBuilder) theMenu); + builder.setOptionalIconsVisible(true); + } + } + + public void createSearchUI(@NonNull final Menu theMenu) { + this.menu = theMenu; + alwaysShowMenuItemIcon(theMenu); + + createSearchUI(); + + MenuCompat.setGroupDividerEnabled(theMenu, true); + } + + public boolean onOptionsItemSelected(@NonNull final MenuItem item) { + if (item.getGroupId() == MENU_GROUP_SEARCH_RESET_BUTTONS + && item.getItemId() == MENU_ID_SEARCH_BUTTON) { + logic.prepareForSearch(); + } else { // all other menu groups -> reset, content filters and sort filters + + // main part for holding onto the menu -> not closing it + item.setShowAsAction(MenuItem.SHOW_AS_ACTION_COLLAPSE_ACTION_VIEW); + item.setActionView(new View(context)); + item.setOnActionExpandListener(new MenuItem.OnActionExpandListener() { + + @Override + public boolean onMenuItemActionExpand(final MenuItem item) { + if (item.getGroupId() == MENU_GROUP_SEARCH_RESET_BUTTONS + && item.getItemId() == MENU_ID_RESET_BUTTON) { + logic.reset(); + } else if (item.getGroupId() < firstSortFilterGroupId) { // content filters + final int filterId = item.getItemId(); + logic.selectContentFilter(filterId); + } else { // the sort filters + Log.d(TAG, "onMenuItemActionExpand: sort filters are here"); + logic.selectSortFilter(item.getItemId()); + } + + return false; + } + + @Override + public boolean onMenuItemActionCollapse(final MenuItem item) { + return false; + } + }); + } + + return false; + } + + @Override + protected ICreateUiForFiltersWorker createSortFilterWorker() { + return new CreateSortFilterUI(); + } + + @Override + protected ICreateUiForFiltersWorker createContentFilterWorker() { + return new CreateContentFilterUI(); + } + + private static class UiItemWrapper implements IUiItemWrapper { + + private final MenuItem item; + + UiItemWrapper(final MenuItem item) { + this.item = item; + } + + @Override + public void setVisible(final boolean visible) { + item.setVisible(visible); + } + + @Override + public int getItemId() { + return item.getItemId(); + } + + @Override + public boolean isChecked() { + return item.isChecked(); + } + + @Override + public void setChecked(final boolean checked) { + item.setChecked(checked); + } + } + + private class CreateContentFilterUI implements ICreateUiForFiltersWorker { + + /** + * MenuItem's that should not be checkable. + */ + final List nonCheckableMenuItems = new ArrayList<>(); + + /** + * {@link Menu#setGroupCheckable(int, boolean, boolean)} makes all {@link MenuItem} + * checkable. + *

+ * We do not want a group header or a group divider to be checkable. Therefore this method + * calls above mentioned method and afterwards makes all items uncheckable that are placed + * inside {@link #nonCheckableMenuItems}. + * + * @param isOnlyOneCheckable is in group only one selection allowed. + * @param groupId which group should be affected + */ + private void makeAllowedMenuItemInGroupCheckable(final boolean isOnlyOneCheckable, + final int groupId) { + // this method makes all MenuItem's checkable + menu.setGroupCheckable(groupId, true, isOnlyOneCheckable); + // uncheckable unwanted + for (final MenuItem uncheckableItem : nonCheckableMenuItems) { + if (uncheckableItem != null) { + uncheckableItem.setCheckable(false); + } + } + nonCheckableMenuItems.clear(); + } + + @Override + public void prepare() { + // create the search button + menu.add(MENU_GROUP_SEARCH_RESET_BUTTONS, + MENU_ID_SEARCH_BUTTON, + 0, + context.getString(R.string.search)) + .setEnabled(true) + .setCheckable(false) + .setIcon(R.drawable.ic_search); + + menu.add(MENU_GROUP_SEARCH_RESET_BUTTONS, + MENU_ID_RESET_BUTTON, + 0, + context.getString(R.string.playback_reset)) + .setEnabled(true) + .setCheckable(false) + .setIcon(R.drawable.ic_settings_backup_restore); + } + + @Override + public void createFilterGroupBeforeItems( + @NonNull final FilterGroup filterGroup) { + if (filterGroup.getNameId() != null) { + createNotEnabledAndUncheckableGroupTitleMenuItem( + FilterContainer.ITEM_IDENTIFIER_UNKNOWN, filterGroup.getNameId()); + } + } + + protected MenuItem createNotEnabledAndUncheckableGroupTitleMenuItem( + final int identifier, + final LibraryStringIds nameId) { + final MenuItem item = menu.add( + newLastUsedGroupId, + identifier, + 0, + ServiceHelper.getTranslatedFilterString(nameId, context)); + item.setEnabled(false); + + nonCheckableMenuItems.add(item); + + return item; + + } + + @Override + public void createFilterItem(@NonNull final FilterItem filterItem, + @NonNull final FilterGroup filterGroup) { + final MenuItem item = createMenuItem(filterItem); + + if (filterItem instanceof DividerItem) { + final DividerItem dividerItem = (DividerItem) filterItem; + final String menuDividerTitle = ">>>" + + context.getString(dividerItem.getStringResId()) + + "<<<"; + item.setTitle(menuDividerTitle); + item.setEnabled(false); + nonCheckableMenuItems.add(item); + } + + logic.addContentFilterUiWrapperToItemMap(filterItem.getIdentifier(), + new UiItemWrapper(item)); + } + + protected MenuItem createMenuItem(final FilterItem filterItem) { + return menu.add(newLastUsedGroupId, + filterItem.getIdentifier(), + 0, + ServiceHelper.getTranslatedFilterString(filterItem.getNameId(), context)); + } + + @Override + public void createFilterGroupAfterItems(@NonNull final FilterGroup filterGroup) { + makeAllowedMenuItemInGroupCheckable(filterGroup.isOnlyOneCheckable(), + getLastUsedGroupIdThanIncrement()); + } + + @Override + public void finish() { + firstSortFilterGroupId = newLastUsedGroupId; + } + + @Override + public void filtersVisible(final boolean areFiltersVisible) { + // no implementation here as there is no 'sort filter' title as MenuItem + } + } + + private class CreateSortFilterUI extends CreateContentFilterUI { + + private void addSortFilterUiToItemMap(final int id, + final MenuItem item) { + logic.addSortFilterUiWrapperToItemMap(id, new UiItemWrapper(item)); + } + + @Override + public void prepare() { + firstSortFilterGroupId = newLastUsedGroupId; + } + + @Override + public void createFilterGroupBeforeItems( + @NonNull final FilterGroup filterGroup) { + if (filterGroup.getNameId() != null) { + final MenuItem item = createNotEnabledAndUncheckableGroupTitleMenuItem( + filterGroup.getIdentifier(), filterGroup.getNameId()); + addSortFilterUiToItemMap(filterGroup.getIdentifier(), item); + } + } + + @Override + public void createFilterItem(@NonNull final FilterItem filterItem, + @NonNull final FilterGroup filterGroup) { + final MenuItem item = createMenuItem(filterItem); + addSortFilterUiToItemMap(filterItem.getIdentifier(), item); + } + + @Override + public void finish() { + // no implementation here as we do not need to clean up anything or whatever + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/UiItemWrapperViews.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/UiItemWrapperViews.java new file mode 100644 index 0000000000..f6b0ed1d3d --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/UiItemWrapperViews.java @@ -0,0 +1,62 @@ +// Created by evermind-zz 2022, licensed GNU GPL version 3 or later + +package org.schabi.newpipe.fragments.list.search.filter; + +import android.view.View; + +import java.util.ArrayList; +import java.util.List; + +import androidx.annotation.NonNull; + +/** + * Wrapper for views that are either just labels or eg. a RadioGroup container + * etc. that represent a {@link org.schabi.newpipe.extractor.search.filter.FilterGroup}. + */ +final class UiItemWrapperViews implements SearchFilterLogic.IUiItemWrapper { + + private final int itemId; + private final List views = new ArrayList<>(); + + UiItemWrapperViews(final int itemId) { + this.itemId = itemId; + } + + public void add(@NonNull final View view) { + this.views.add(view); + } + + @Override + public void setVisible(final boolean visible) { + for (final View view : views) { + if (visible) { + view.setVisibility(View.VISIBLE); + } else { + view.setVisibility(View.GONE); + } + } + } + + @Override + public int getItemId() { + return this.itemId; + } + + @Override + public boolean isChecked() { + boolean isChecked = false; + for (final View view : views) { + if (view.isSelected()) { + isChecked = true; + break; + } + } + return isChecked; + } + + @Override + public void setChecked(final boolean checked) { + // not relevant as here views are wrapped that are either just labels or eg. a + // RadioGroup container etc. that represent a FilterGroup. + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/LocalPlayer.java b/app/src/main/java/org/schabi/newpipe/player/LocalPlayer.java new file mode 100644 index 0000000000..63b7b70fdf --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/LocalPlayer.java @@ -0,0 +1,370 @@ +package org.schabi.newpipe.player; + +import android.content.Context; +import android.content.SharedPreferences; +import android.net.Uri; +import android.util.Log; +import android.widget.Toast; + +import androidx.preference.PreferenceManager; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.MediaItem; +import com.google.android.exoplayer2.PlaybackParameters; +import com.google.android.exoplayer2.SeekParameters; +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.ProgressiveMediaSource; +import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; + +import org.schabi.newpipe.DownloaderImpl; +import org.schabi.newpipe.R; +import org.schabi.newpipe.player.helper.PlayerHelper; +import org.schabi.newpipe.util.VideoSegment; + +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; +import io.reactivex.rxjava3.core.Observable; +import io.reactivex.rxjava3.disposables.Disposable; +import io.reactivex.rxjava3.disposables.SerialDisposable; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.schabi.newpipe.player.Player.STATE_BLOCKED; +import static org.schabi.newpipe.player.Player.STATE_BUFFERING; +import static org.schabi.newpipe.player.Player.STATE_COMPLETED; +import static org.schabi.newpipe.player.Player.STATE_PAUSED; +import static org.schabi.newpipe.player.Player.STATE_PAUSED_SEEK; +import static org.schabi.newpipe.player.Player.STATE_PLAYING; + +public class LocalPlayer implements com.google.android.exoplayer2.Player.Listener { + private static final String TAG = "LocalPlayer"; + private static final int PROGRESS_LOOP_INTERVAL_MILLIS = 500; + + private final Context context; + private final SharedPreferences mPrefs; + private SimpleExoPlayer simpleExoPlayer; + private SerialDisposable progressUpdateReactor; + private VideoSegment[] videoSegments; + private LocalPlayerListener listener; + private int lastCurrentProgress = -1; + private int lastSkipTarget = -1; + + public LocalPlayer(final Context context) { + this.context = context; + this.mPrefs = PreferenceManager.getDefaultSharedPreferences(context); + } + + public void initialize(final String uri, final VideoSegment[] segments) { + this.videoSegments = segments; + this.progressUpdateReactor = new SerialDisposable(); + + simpleExoPlayer = new SimpleExoPlayer + .Builder(context) + .build(); + simpleExoPlayer.addListener(this); + simpleExoPlayer.setSeekParameters(PlayerHelper.getSeekParameters(context)); + simpleExoPlayer.setHandleAudioBecomingNoisy(true); + simpleExoPlayer.setWakeMode(C.WAKE_MODE_NETWORK); + + final PlaybackParameters playbackParameters = simpleExoPlayer.getPlaybackParameters(); + final float speed = mPrefs.getFloat(context.getString( + R.string.playback_speed_key), playbackParameters.speed); + final float pitch = mPrefs.getFloat(context.getString( + R.string.playback_pitch_key), playbackParameters.pitch); + + boolean defaultSkipSilence = false; + if (simpleExoPlayer.getAudioComponent() != null) { + defaultSkipSilence = simpleExoPlayer.getAudioComponent().getSkipSilenceEnabled(); + } + + final boolean skipSilence = mPrefs.getBoolean(context.getString( + R.string.playback_skip_silence_key), defaultSkipSilence); + + setPlaybackParameters(speed, pitch, skipSilence); + + final String autoPlayStr = + mPrefs.getString(context.getString(R.string.autoplay_key), ""); + final boolean autoPlay = + !autoPlayStr.equals(context.getString(R.string.autoplay_never_key)); + + simpleExoPlayer.setPlayWhenReady(autoPlay); + + if (uri == null || uri.length() == 0) { + return; + } + + final MediaItem mediaItem = new MediaItem.Builder() + .setUri(Uri.parse(uri)) + .build(); + + final MediaSource videoSource = new ProgressiveMediaSource + .Factory(new DefaultDataSourceFactory(context, DownloaderImpl.USER_AGENT)) + .createMediaSource(mediaItem); + + simpleExoPlayer.addMediaSource(videoSource); + simpleExoPlayer.prepare(); + } + + public SimpleExoPlayer getExoPlayer() { + return this.simpleExoPlayer; + } + + public void setListener(final LocalPlayerListener listener) { + this.listener = listener; + } + + public void destroy() { + simpleExoPlayer.removeListener(this); + simpleExoPlayer.stop(); + simpleExoPlayer.release(); + progressUpdateReactor.set(null); + } + + public void setPlaybackParameters(final float speed, final float pitch, + final boolean skipSilence) { + final float roundedSpeed = Math.round(speed * 100.0f) / 100.0f; + final float roundedPitch = Math.round(pitch * 100.0f) / 100.0f; + + mPrefs.edit() + .putFloat(context.getString(R.string.playback_speed_key), speed) + .putFloat(context.getString(R.string.playback_pitch_key), pitch) + .putBoolean(context.getString(R.string.playback_skip_silence_key), skipSilence) + .apply(); + + simpleExoPlayer.setPlaybackParameters( + new PlaybackParameters(roundedSpeed, roundedPitch)); + + if (simpleExoPlayer.getAudioComponent() != null) { + simpleExoPlayer.getAudioComponent().setSkipSilenceEnabled(skipSilence); + } + } + + @Override + public void onPlayerStateChanged(final boolean playWhenReady, final int playbackState) { + switch (playbackState) { + case com.google.android.exoplayer2.Player.STATE_IDLE: + break; + case com.google.android.exoplayer2.Player.STATE_BUFFERING: + break; + case com.google.android.exoplayer2.Player.STATE_READY: + changeState(playWhenReady ? STATE_PLAYING : STATE_PAUSED); + break; + case com.google.android.exoplayer2.Player.STATE_ENDED: + changeState(STATE_COMPLETED); + break; + } + } + + private boolean isProgressLoopRunning() { + return progressUpdateReactor.get() != null; + } + + private void startProgressLoop() { + progressUpdateReactor.set(getProgressReactor()); + } + + private void stopProgressLoop() { + progressUpdateReactor.set(null); + } + + private Disposable getProgressReactor() { + return Observable.interval(PROGRESS_LOOP_INTERVAL_MILLIS, MILLISECONDS, + AndroidSchedulers.mainThread()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(ignored -> triggerProgressUpdate(), + error -> Log.e(TAG, "Progress update failure: ", error)); + } + + private void changeState(final int state) { + switch (state) { + case STATE_BLOCKED: + onBlocked(); + break; + case STATE_PLAYING: + onPlaying(); + break; + case STATE_BUFFERING: + onBuffering(); + break; + case STATE_PAUSED: + onPaused(); + break; + case STATE_PAUSED_SEEK: + onPausedSeek(); + break; + case STATE_COMPLETED: + onCompleted(); + break; + } + } + + private void onBlocked() { + if (!isProgressLoopRunning()) { + startProgressLoop(); + } + + if (listener != null) { + listener.onBlocked(simpleExoPlayer); + } + } + + private void onPlaying() { + if (!isProgressLoopRunning()) { + startProgressLoop(); + } + + if (listener != null) { + listener.onPlaying(simpleExoPlayer); + } + } + + private void onBuffering() { + if (listener != null) { + listener.onBuffering(simpleExoPlayer); + } + } + + private void onPaused() { + if (isProgressLoopRunning()) { + stopProgressLoop(); + } + + if (listener != null) { + listener.onPaused(simpleExoPlayer); + } + } + + private void onPausedSeek() { + if (listener != null) { + listener.onPausedSeek(simpleExoPlayer); + } + } + + private void onCompleted() { + if (isProgressLoopRunning()) { + stopProgressLoop(); + } + + if (listener != null) { + listener.onCompleted(simpleExoPlayer); + } + } + + private void triggerProgressUpdate() { + if (simpleExoPlayer == null) { + return; + } + final int currentProgress = Math.max((int) simpleExoPlayer.getCurrentPosition(), 0); + + final boolean isRewind = currentProgress < lastCurrentProgress; + + lastCurrentProgress = currentProgress; + + if (!mPrefs.getBoolean( + context.getString(R.string.sponsor_block_enable_key), false)) { + return; + } + + final VideoSegment segment = getSkippableSegment(currentProgress); + if (segment == null) { + lastSkipTarget = -1; + return; + } + + int skipTarget = isRewind + ? (int) Math.ceil((segment.startTime)) - 1 + : (int) Math.ceil((segment.endTime)); + + if (skipTarget < 0) { + skipTarget = 0; + } + + if (lastSkipTarget == skipTarget) { + return; + } + + lastSkipTarget = skipTarget; + + // temporarily force EXACT seek parameters to prevent infinite skip looping + final SeekParameters seekParams = simpleExoPlayer.getSeekParameters(); + simpleExoPlayer.setSeekParameters(SeekParameters.EXACT); + + seekTo(skipTarget); + + simpleExoPlayer.setSeekParameters(seekParams); + + if (mPrefs.getBoolean( + context.getString(R.string.sponsor_block_notifications_key), false)) { + String toastText = ""; + + switch (segment.category) { + case "sponsor": + toastText = context + .getString(R.string.sponsor_block_skip_sponsor_toast); + break; + case "intro": + toastText = context + .getString(R.string.sponsor_block_skip_intro_toast); + break; + case "outro": + toastText = context + .getString(R.string.sponsor_block_skip_outro_toast); + break; + case "interaction": + toastText = context + .getString(R.string.sponsor_block_skip_interaction_toast); + break; + case "selfpromo": + toastText = context + .getString(R.string.sponsor_block_skip_self_promo_toast); + break; + case "music_offtopic": + toastText = context + .getString(R.string.sponsor_block_skip_non_music_toast); + break; + case "preview": + toastText = context + .getString(R.string.sponsor_block_skip_preview_toast); + break; + case "filler": + toastText = context + .getString(R.string.sponsor_block_skip_filler_toast); + break; + } + + Toast.makeText(context, toastText, Toast.LENGTH_SHORT).show(); + } + } + + private void seekTo(final long positionMillis) { + if (simpleExoPlayer != null) { + long normalizedPositionMillis = positionMillis; + if (normalizedPositionMillis < 0) { + normalizedPositionMillis = 0; + } else if (normalizedPositionMillis > simpleExoPlayer.getDuration()) { + normalizedPositionMillis = simpleExoPlayer.getDuration(); + } + + simpleExoPlayer.seekTo(normalizedPositionMillis); + } + } + + private VideoSegment getSkippableSegment(final int progress) { + if (videoSegments == null) { + return null; + } + + for (final VideoSegment segment : videoSegments) { + if (progress < segment.startTime) { + continue; + } + + if (progress > segment.endTime) { + continue; + } + + return segment; + } + + return null; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/LocalPlayerListener.java b/app/src/main/java/org/schabi/newpipe/player/LocalPlayerListener.java new file mode 100644 index 0000000000..1035345453 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/LocalPlayerListener.java @@ -0,0 +1,12 @@ +package org.schabi.newpipe.player; + +import com.google.android.exoplayer2.SimpleExoPlayer; + +public interface LocalPlayerListener { + void onBlocked(SimpleExoPlayer player); + void onPlaying(SimpleExoPlayer player); + void onBuffering(SimpleExoPlayer player); + void onPaused(SimpleExoPlayer player); + void onPausedSeek(SimpleExoPlayer player); + void onCompleted(SimpleExoPlayer player); +} diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java b/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java index d5627dd3b9..7d5c4ac5c4 100644 --- a/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java +++ b/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java @@ -5,8 +5,10 @@ import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; import android.content.ComponentName; +import android.content.Context; import android.content.Intent; import android.content.ServiceConnection; +import android.content.SharedPreferences; import android.os.Bundle; import android.os.IBinder; import android.provider.Settings; @@ -21,6 +23,7 @@ import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; +import androidx.preference.PreferenceManager; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; @@ -52,6 +55,8 @@ import java.util.List; import java.util.Optional; +import static org.schabi.newpipe.util.SponsorBlockUtils.markSegments; + public final class PlayQueueActivity extends AppCompatActivity implements PlayerEventListener, SeekBar.OnSeekBarChangeListener, View.OnClickListener, PlaybackParameterDialog.Callback { @@ -236,6 +241,16 @@ public void onServiceConnected(final ComponentName name, final IBinder service) getApplicationContext(), queueControlBinding.seekBar, info)); + // BRAVE NEWPIPE CONFLICT + // if (player != null) { + // final PlayQueueItem item = player.getPlayQueue().getItem(); + // final Context context = getApplicationContext(); + // final SharedPreferences prefs = + // PreferenceManager.getDefaultSharedPreferences(context); + // markSegments(item, queueControlBinding.seekBar, context, prefs); + + // player.setActivityListener(PlayQueueActivity.this); + // } } } }; diff --git a/app/src/main/java/org/schabi/newpipe/player/Player.java b/app/src/main/java/org/schabi/newpipe/player/Player.java index 60f6612f7c..3cad1ec04b 100644 --- a/app/src/main/java/org/schabi/newpipe/player/Player.java +++ b/app/src/main/java/org/schabi/newpipe/player/Player.java @@ -127,7 +127,9 @@ import org.schabi.newpipe.util.SponsorBlockHelper; import org.schabi.newpipe.util.image.PicassoHelper; import org.schabi.newpipe.util.SerializedCache; +import org.schabi.newpipe.util.SponsorBlockMode; import org.schabi.newpipe.util.StreamTypeUtil; +import org.schabi.newpipe.util.VideoSegment; import java.util.List; import java.util.Optional; @@ -168,6 +170,7 @@ public final class Player implements PlaybackListener, Listener { public static final String PLAY_WHEN_READY = "play_when_ready"; public static final String PLAYER_TYPE = "player_type"; public static final String IS_MUTED = "is_muted"; + public static final String VIDEO_SEGMENTS = "video_segments"; /*////////////////////////////////////////////////////////////////////////// // Time constants @@ -275,6 +278,12 @@ public final class Player implements PlaybackListener, Listener { private boolean autoSkipGracePeriod = false; private final SharedPreferences.OnSharedPreferenceChangeListener preferenceChangeListener; + /*////////////////////////////////////////////////////////////////////////// + // SponsorBlock (BRAVE NEWPIPE CONFLICT) + //////////////////////////////////////////////////////////////////////////*/ + // private SponsorBlockMode sponsorBlockMode = SponsorBlockMode.DISABLED; + // private int lastSkipTarget = -1; + /*////////////////////////////////////////////////////////////////////////// // Constructor @@ -495,6 +504,8 @@ public void handleIntent(@NonNull final Intent intent) { // (to disable/enable video stream or to set quality) setRecovery(); reloadPlayQueueManager(); + stopProgressLoop(); + startProgressLoop(); } UIs.call(PlayerUi::setupAfterIntent); diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java b/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java index dbe103cfab..31aa966c74 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java +++ b/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java @@ -27,7 +27,9 @@ import org.schabi.newpipe.player.playqueue.events.PlayQueueEvent; import org.schabi.newpipe.player.playqueue.events.RemoveEvent; import org.schabi.newpipe.player.playqueue.events.ReorderEvent; +import org.schabi.newpipe.util.SponsorBlockUtils; +import java.io.UnsupportedEncodingException; import java.util.Collection; import java.util.Collections; import java.util.Optional; @@ -435,6 +437,13 @@ private Single getLoadedMediaSource(@NonNull final PlayQueue final int serviceId = streamInfo.getServiceId(); final long expiration = System.currentTimeMillis() + getCacheExpirationMillis(serviceId); + try { + stream.setVideoSegments( + SponsorBlockUtils.getYouTubeVideoSegments( + context, streamInfo)); + } catch (final UnsupportedEncodingException e) { + throw new RuntimeException(e); + } return new LoadedMediaSource(source, tag, stream, expiration); }) diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.java index 0e1d58cd0c..83d41d0e11 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.java +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.java @@ -10,6 +10,7 @@ import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.extractor.stream.StreamType; import org.schabi.newpipe.util.ExtractorHelper; +import org.schabi.newpipe.util.VideoSegment; import java.io.Serializable; import java.util.List; @@ -40,6 +41,8 @@ public class PlayQueueItem implements Serializable { private long recoveryPosition; private Throwable error; + private VideoSegment[] videoSegments; + PlayQueueItem(@NonNull final StreamInfo info) { this(info.getName(), info.getUrl(), info.getServiceId(), info.getDuration(), info.getThumbnails(), info.getUploaderName(), @@ -141,4 +144,12 @@ public boolean isAutoQueued() { public void setAutoQueued(final boolean autoQueued) { isAutoQueued = autoQueued; } + + public VideoSegment[] getVideoSegments() { + return videoSegments; + } + + public void setVideoSegments(final VideoSegment[] videoSegments) { + this.videoSegments = videoSegments; + } } diff --git a/app/src/main/java/org/schabi/newpipe/player/ui/MainPlayerUi.java b/app/src/main/java/org/schabi/newpipe/player/ui/MainPlayerUi.java index 03f90a3446..73da0f55d3 100644 --- a/app/src/main/java/org/schabi/newpipe/player/ui/MainPlayerUi.java +++ b/app/src/main/java/org/schabi/newpipe/player/ui/MainPlayerUi.java @@ -101,7 +101,6 @@ public final class MainPlayerUi extends VideoPlayerUi implements View.OnLayoutCh // fullscreen player private ItemTouchHelper itemTouchHelper; - /*////////////////////////////////////////////////////////////////////////// // Constructor, setup, destroy //////////////////////////////////////////////////////////////////////////*/ @@ -960,7 +959,6 @@ public void checkLandscape() { } //endregion - /*////////////////////////////////////////////////////////////////////////// // Getters //////////////////////////////////////////////////////////////////////////*/ diff --git a/app/src/main/java/org/schabi/newpipe/player/ui/PopupPlayerUi.java b/app/src/main/java/org/schabi/newpipe/player/ui/PopupPlayerUi.java index 90c24c0c6c..cd5c61d41b 100644 --- a/app/src/main/java/org/schabi/newpipe/player/ui/PopupPlayerUi.java +++ b/app/src/main/java/org/schabi/newpipe/player/ui/PopupPlayerUi.java @@ -175,6 +175,10 @@ protected void setupElementsVisibility() { binding.topControls.setFocusable(false); binding.bottomControls.bringToFront(); super.setupElementsVisibility(); + + // hide the SponsorBlock button from the pop-up player because + // it looks bad and the UI is going to change in Tubular anyway... + binding.switchSponsorBlocking.setVisibility(View.GONE); } @Override @@ -436,7 +440,6 @@ protected void onPlaybackSpeedClicked() { } //endregion - /*////////////////////////////////////////////////////////////////////////// // Gestures //////////////////////////////////////////////////////////////////////////*/ diff --git a/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.java b/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.java index 73c4ef35d8..3aa1e01797 100644 --- a/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.java +++ b/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.java @@ -15,6 +15,7 @@ import static org.schabi.newpipe.player.helper.PlayerHelper.getTimeString; import static org.schabi.newpipe.player.helper.PlayerHelper.nextResizeModeAndSaveToPrefs; import static org.schabi.newpipe.player.helper.PlayerHelper.retrieveSeekDurationFromPreferences; +import static org.schabi.newpipe.util.SponsorBlockUtils.markSegments; import android.content.Intent; import android.content.res.Resources; @@ -33,9 +34,11 @@ import android.view.Menu; import android.view.MenuItem; import android.view.View; +import android.widget.ImageButton; import android.widget.LinearLayout; import android.widget.RelativeLayout; import android.widget.SeekBar; +import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -83,13 +86,16 @@ import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.SponsorBlockHelper; +// BRAVE NEWPIPE CONFLICT // import org.schabi.newpipe.util.SponsorBlockMode; import org.schabi.newpipe.util.external_communication.KoreUtils; import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.views.player.PlayerFastSeekOverlay; +import java.util.HashSet; import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.stream.Collectors; public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBarChangeListener, @@ -149,7 +155,6 @@ private enum PlayButtonAction { private final SeekbarPreviewThumbnailHolder seekbarPreviewThumbnailHolder = new SeekbarPreviewThumbnailHolder(); - /*////////////////////////////////////////////////////////////////////////// // Constructor, setup, destroy //////////////////////////////////////////////////////////////////////////*/ @@ -250,6 +255,11 @@ protected void initListeners() { )); binding.switchMute.setOnClickListener(makeOnClickListener(player::toggleMute)); + binding.switchSponsorBlocking.setOnClickListener( + makeOnClickListener(this::onBlockingSponsorsButtonClicked)); + binding.switchSponsorBlocking.setOnLongClickListener( + makeOnLongClickListener(this::onBlockingSponsorsButtonLongClicked)); + ViewCompat.setOnApplyWindowInsetsListener(binding.itemsListPanel, (view, windowInsets) -> { final Insets cutout = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()); if (!cutout.equals(Insets.NONE)) { @@ -426,6 +436,13 @@ public void destroy() { protected void setupElementsVisibility() { setMuteButton(player.isMuted()); animateRotation(binding.moreOptionsButton, DEFAULT_CONTROLS_DURATION, 0); + + final boolean isSponsorBlockEnabled = player.getPrefs().getBoolean( + context.getString(R.string.sponsor_block_enable_key), false); + binding.switchSponsorBlocking.setVisibility( + isSponsorBlockEnabled ? View.VISIBLE : View.GONE); + + setBlockSponsorsButton(binding.switchSponsorBlocking); } protected abstract void setupElementsSize(Resources resources); @@ -800,6 +817,7 @@ public void onPrepared() { super.onPrepared(); setVideoDurationToControls((int) player.getExoPlayer().getDuration()); binding.playbackSpeed.setText(formatSpeed(player.getPlaybackSpeed())); + markSegments(player.getCurrentItem(), binding.playbackSeekBar, context, player.getPrefs()); } @Override @@ -1060,6 +1078,21 @@ public void onMetadataChanged(@NonNull final StreamInfo info) { binding.channelTextView.setText(info.getUploaderName()); this.seekbarPreviewThumbnailHolder.resetFrom(player.getContext(), info.getPreviewFrames()); + + final boolean isSponsorBlockEnabled = player.getPrefs().getBoolean( + context.getString(R.string.sponsor_block_enable_key), false); + final Set uploaderWhitelist = player.getPrefs().getStringSet( + context.getString(R.string.sponsor_block_whitelist_key), null); + + if (uploaderWhitelist != null && uploaderWhitelist.contains(info.getUploaderName())) { + player.setSponsorBlockMode(SponsorBlockMode.IGNORE); + } else { + player.setSponsorBlockMode(isSponsorBlockEnabled + ? SponsorBlockMode.ENABLED + : SponsorBlockMode.DISABLED); + } + + setBlockSponsorsButton(binding.switchSponsorBlocking); } private void updateStreamRelatedViews() { @@ -1497,6 +1530,37 @@ protected View.OnClickListener makeOnClickListener(@NonNull final Runnable runna }; } + protected View.OnLongClickListener makeOnLongClickListener(@NonNull final Runnable runnable) { + return v -> { + if (DEBUG) { + Log.d(TAG, "onLongClick() called with: v = [" + v + "]"); + } + + runnable.run(); + + // Manages the player controls after handling the view click. + if (player.getCurrentState() == STATE_COMPLETED) { + return true; + } + controlsVisibilityHandler.removeCallbacksAndMessages(null); + showHideShadow(true, DEFAULT_CONTROLS_DURATION); + animate(binding.playbackControlRoot, true, DEFAULT_CONTROLS_DURATION, + AnimationType.ALPHA, 0, () -> { + if (player.getCurrentState() == STATE_PLAYING && !isSomePopupMenuVisible) { + if (v == binding.playPauseButton + // Hide controls in fullscreen immediately + || (v == binding.screenRotationButton && isFullscreen())) { + hideControls(0, 0); + } else { + hideControls(DEFAULT_CONTROLS_DURATION, DEFAULT_CONTROLS_HIDE_TIME); + } + } + }); + + return true; + }; + } + public boolean onKeyDown(final int keyCode) { switch (keyCode) { case KeyEvent.KEYCODE_BACK: @@ -1593,6 +1657,96 @@ public void onVideoSizeChanged(@NonNull final VideoSize videoSize) { } //endregion + /*////////////////////////////////////////////////////////////////////////// + // SponsorBlock + //////////////////////////////////////////////////////////////////////////*/ + //region + + public void onBlockingSponsorsButtonClicked() { + if (DEBUG) { + Log.d(TAG, "onBlockingSponsorsButtonClicked() called"); + } + + switch (player.getSponsorBlockMode()) { + case DISABLED: + player.setSponsorBlockMode(SponsorBlockMode.ENABLED); + break; + case ENABLED: + player.setSponsorBlockMode(SponsorBlockMode.DISABLED); + break; + case IGNORE: + // ignored + } + + setBlockSponsorsButton(binding.switchSponsorBlocking); + } + + public void onBlockingSponsorsButtonLongClicked() { + if (DEBUG) { + Log.d(TAG, "onBlockingSponsorsButtonLongClicked() called"); + } + + final MediaItemTag metaData = player.getCurrentMetadata(); + + if (metaData == null) { + return; + } + + final Set uploaderWhitelist = new HashSet<>(player.getPrefs().getStringSet( + context.getString(R.string.sponsor_block_whitelist_key), + new HashSet<>())); + + final String toastText; + + final String uploaderName = metaData.getUploaderName(); + + if (player.getSponsorBlockMode() == SponsorBlockMode.IGNORE) { + uploaderWhitelist.remove(uploaderName); + player.setSponsorBlockMode(SponsorBlockMode.ENABLED); + toastText = context + .getString(R.string.sponsor_block_uploader_removed_from_whitelist_toast); + } else { + uploaderWhitelist.add(uploaderName); + player.setSponsorBlockMode(SponsorBlockMode.IGNORE); + toastText = context + .getString(R.string.sponsor_block_uploader_added_to_whitelist_toast); + } + + player.getPrefs() + .edit() + .putStringSet( + context.getString(R.string.sponsor_block_whitelist_key), + new HashSet<>(uploaderWhitelist)) + .apply(); + + setBlockSponsorsButton(binding.switchSponsorBlocking); + Toast.makeText(context, toastText, Toast.LENGTH_LONG).show(); + } + + protected void setBlockSponsorsButton(final ImageButton button) { + if (button == null) { + return; + } + + final int resId; + + switch (player.getSponsorBlockMode()) { + case DISABLED: + resId = R.drawable.ic_sponsor_block_disable; + break; + case ENABLED: + resId = R.drawable.ic_sponsor_block_enable; + break; + case IGNORE: + resId = R.drawable.ic_sponsor_block_exclude; + break; + default: + return; + } + + button.setImageDrawable(AppCompatResources.getDrawable(player.getService(), resId)); + } + //endregion /*////////////////////////////////////////////////////////////////////////// // SurfaceHolderCallback helpers diff --git a/app/src/main/java/org/schabi/newpipe/settings/BraveBasePreferenceFragment.java b/app/src/main/java/org/schabi/newpipe/settings/BraveBasePreferenceFragment.java new file mode 100644 index 0000000000..928cfadd36 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/BraveBasePreferenceFragment.java @@ -0,0 +1,35 @@ +package org.schabi.newpipe.settings; + +import android.os.Bundle; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + + +/** + * Inherit from this class instead of {@link BasePreferenceFragment} to manipulate config options. + *

+ * If you have a fork and flavors and want to alter some config options use this class especially + * overwrite the {@link #manipulateCreatedPreferenceOptions()} in which you can manipulate + */ +public abstract class BraveBasePreferenceFragment extends BasePreferenceFragment { + + /** + * After creation of this settings fragment you may want to manipulate + * some settings. + *

+ * Eg. if you've some flavor's and want them to have different options + * here is a good place to manipulate them programmatically. + */ + protected void manipulateCreatedPreferenceOptions() { + } + + @Override + public void onViewCreated( + @NonNull final View rootView, + @Nullable final Bundle savedInstanceState) { + super.onViewCreated(rootView, savedInstanceState); + manipulateCreatedPreferenceOptions(); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/BraveSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/BraveSettingsFragment.java new file mode 100644 index 0000000000..41d20984b0 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/BraveSettingsFragment.java @@ -0,0 +1,10 @@ +package org.schabi.newpipe.settings; + +import android.os.Bundle; + +public class BraveSettingsFragment extends BasePreferenceFragment { + @Override + public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { + addPreferencesFromResourceRegistry(); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/ExtraSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/ExtraSettingsFragment.java new file mode 100644 index 0000000000..a5e1ea4076 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/ExtraSettingsFragment.java @@ -0,0 +1,10 @@ +package org.schabi.newpipe.settings; + +import android.os.Bundle; + +public class ExtraSettingsFragment extends BasePreferenceFragment { + @Override + public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { + addPreferencesFromResourceRegistry(); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java b/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java index dd32426ae0..251d436fd7 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java +++ b/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java @@ -58,6 +58,7 @@ public static void initSettings(final Context context) { PreferenceManager.setDefaultValues(context, R.xml.player_notification_settings, true); PreferenceManager.setDefaultValues(context, R.xml.update_settings, true); PreferenceManager.setDefaultValues(context, R.xml.debug_settings, true); + PreferenceManager.setDefaultValues(context, R.xml.sponsor_block_category_settings, true); PreferenceManager.setDefaultValues(context, R.xml.backup_restore_settings, true); PreferenceManager.setDefaultValues(context, R.xml.sponsor_block_settings, true); PreferenceManager.setDefaultValues(context, R.xml.sponsor_block_category_settings, true); diff --git a/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java b/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java index 0bf263d373..8bf8c9eb88 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java +++ b/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java @@ -45,6 +45,7 @@ private SettingsResourceRegistry() { add(SponsorBlockSettingsFragment.class, R.xml.sponsor_block_settings); add(SponsorBlockCategoriesSettingsFragment.class, R.xml.sponsor_block_category_settings); add(ReturnYouTubeDislikeSettingsFragment.class, R.xml.return_youtube_dislikes_settings); + add(BraveSettingsFragment.class, R.xml.brave_settings); } private SettingRegistryEntry add( diff --git a/app/src/main/java/org/schabi/newpipe/settings/SponsorBlockCategoriesSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/SponsorBlockCategoriesSettingsFragment.java index 20cd7ceb42..56daed2a57 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/SponsorBlockCategoriesSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/SponsorBlockCategoriesSettingsFragment.java @@ -2,6 +2,7 @@ import android.content.SharedPreferences; import android.os.Bundle; +import android.widget.Toast; import androidx.annotation.ColorRes; import androidx.annotation.Nullable; diff --git a/app/src/main/java/org/schabi/newpipe/settings/SponsorBlockSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/SponsorBlockSettingsFragment.java index 024834cebf..b2844d45cf 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/SponsorBlockSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/SponsorBlockSettingsFragment.java @@ -5,6 +5,10 @@ import android.os.Bundle; import android.widget.Toast; +import android.view.View; +import android.widget.Toast; + +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.preference.Preference; diff --git a/app/src/main/java/org/schabi/newpipe/settings/VideoAudioSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/VideoAudioSettingsFragment.java index a1f563724e..4a1e2499e4 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/VideoAudioSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/VideoAudioSettingsFragment.java @@ -19,7 +19,7 @@ import java.util.LinkedList; import java.util.List; -public class VideoAudioSettingsFragment extends BasePreferenceFragment { +public class VideoAudioSettingsFragment extends BraveVideoAudioSettingsBaseFragment { private SharedPreferences.OnSharedPreferenceChangeListener listener; @Override diff --git a/app/src/main/java/org/schabi/newpipe/util/ChannelTabHelper.java b/app/src/main/java/org/schabi/newpipe/util/ChannelTabHelper.java index 8e8d384900..633de1b9a4 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ChannelTabHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ChannelTabHelper.java @@ -8,6 +8,7 @@ import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.channel.tabs.ChannelTabs; import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; +import org.schabi.newpipe.extractor.search.filter.FilterItem; import java.util.List; import java.util.Set; @@ -20,16 +21,11 @@ private ChannelTabHelper() { * @param tab the channel tab to check * @return whether the tab should contain (playable) streams or not */ - public static boolean isStreamsTab(final String tab) { - switch (tab) { - case ChannelTabs.VIDEOS: - case ChannelTabs.TRACKS: - case ChannelTabs.SHORTS: - case ChannelTabs.LIVESTREAMS: - return true; - default: - return false; - } + public static boolean isStreamsTab(final FilterItem tab) { + return tab.equals(ChannelTabs.VIDEOS) + || tab.equals(ChannelTabs.TRACKS) + || tab.equals(ChannelTabs.SHORTS) + || tab.equals(ChannelTabs.LIVESTREAMS); } /** @@ -37,7 +33,7 @@ public static boolean isStreamsTab(final String tab) { * @return whether the tab should contain (playable) streams or not */ public static boolean isStreamsTab(final ListLinkHandler tab) { - final List contentFilters = tab.getContentFilters(); + final List contentFilters = tab.getContentFilters(); if (contentFilters.isEmpty()) { return false; // this should never happen, but check just to be sure } else { @@ -46,63 +42,57 @@ public static boolean isStreamsTab(final ListLinkHandler tab) { } @StringRes - private static int getShowTabKey(final String tab) { - switch (tab) { - case ChannelTabs.VIDEOS: - return R.string.show_channel_tabs_videos; - case ChannelTabs.TRACKS: - return R.string.show_channel_tabs_tracks; - case ChannelTabs.SHORTS: - return R.string.show_channel_tabs_shorts; - case ChannelTabs.LIVESTREAMS: - return R.string.show_channel_tabs_livestreams; - case ChannelTabs.CHANNELS: - return R.string.show_channel_tabs_channels; - case ChannelTabs.PLAYLISTS: - return R.string.show_channel_tabs_playlists; - case ChannelTabs.ALBUMS: - return R.string.show_channel_tabs_albums; - default: - return -1; + private static int getShowTabKey(final FilterItem tab) { + if (tab.equals(ChannelTabs.VIDEOS)) { + return R.string.show_channel_tabs_videos; + } else if (tab.equals(ChannelTabs.TRACKS)) { + return R.string.show_channel_tabs_tracks; + } else if (tab.equals(ChannelTabs.SHORTS)) { + return R.string.show_channel_tabs_shorts; + } else if (tab.equals(ChannelTabs.LIVESTREAMS)) { + return R.string.show_channel_tabs_livestreams; + } else if (tab.equals(ChannelTabs.CHANNELS)) { + return R.string.show_channel_tabs_channels; + } else if (tab.equals(ChannelTabs.PLAYLISTS)) { + return R.string.show_channel_tabs_playlists; + } else if (tab.equals(ChannelTabs.ALBUMS)) { + return R.string.show_channel_tabs_albums; } + return -1; } @StringRes - private static int getFetchFeedTabKey(final String tab) { - switch (tab) { - case ChannelTabs.VIDEOS: - return R.string.fetch_channel_tabs_videos; - case ChannelTabs.TRACKS: - return R.string.fetch_channel_tabs_tracks; - case ChannelTabs.SHORTS: - return R.string.fetch_channel_tabs_shorts; - case ChannelTabs.LIVESTREAMS: - return R.string.fetch_channel_tabs_livestreams; - default: - return -1; + private static int getFetchFeedTabKey(final FilterItem tab) { + if (tab.equals(ChannelTabs.VIDEOS)) { + return R.string.fetch_channel_tabs_videos; + } else if (tab.equals(ChannelTabs.TRACKS)) { + return R.string.fetch_channel_tabs_tracks; + } else if (tab.equals(ChannelTabs.SHORTS)) { + return R.string.fetch_channel_tabs_shorts; + } else if (tab.equals(ChannelTabs.LIVESTREAMS)) { + return R.string.fetch_channel_tabs_livestreams; } + return -1; } @StringRes - public static int getTranslationKey(final String tab) { - switch (tab) { - case ChannelTabs.VIDEOS: - return R.string.channel_tab_videos; - case ChannelTabs.TRACKS: - return R.string.channel_tab_tracks; - case ChannelTabs.SHORTS: - return R.string.channel_tab_shorts; - case ChannelTabs.LIVESTREAMS: - return R.string.channel_tab_livestreams; - case ChannelTabs.CHANNELS: - return R.string.channel_tab_channels; - case ChannelTabs.PLAYLISTS: - return R.string.channel_tab_playlists; - case ChannelTabs.ALBUMS: - return R.string.channel_tab_albums; - default: - return R.string.unknown_content; + public static int getTranslationKey(final FilterItem tab) { + if (tab.equals(ChannelTabs.VIDEOS)) { + return R.string.channel_tab_videos; + } else if (tab.equals(ChannelTabs.TRACKS)) { + return R.string.channel_tab_tracks; + } else if (tab.equals(ChannelTabs.SHORTS)) { + return R.string.channel_tab_shorts; + } else if (tab.equals(ChannelTabs.LIVESTREAMS)) { + return R.string.channel_tab_livestreams; + } else if (tab.equals(ChannelTabs.CHANNELS)) { + return R.string.channel_tab_channels; + } else if (tab.equals(ChannelTabs.PLAYLISTS)) { + return R.string.channel_tab_playlists; + } else if (tab.equals(ChannelTabs.ALBUMS)) { + return R.string.channel_tab_albums; } + return R.string.unknown_content; } public static boolean showChannelTab(final Context context, @@ -119,7 +109,7 @@ public static boolean showChannelTab(final Context context, public static boolean showChannelTab(final Context context, final SharedPreferences sharedPreferences, - final String tab) { + final FilterItem tab) { final int key = ChannelTabHelper.getShowTabKey(tab); if (key == -1) { return false; @@ -130,7 +120,7 @@ public static boolean showChannelTab(final Context context, public static boolean fetchFeedChannelTab(final Context context, final SharedPreferences sharedPreferences, final ListLinkHandler tab) { - final List contentFilters = tab.getContentFilters(); + final List contentFilters = tab.getContentFilters(); if (contentFilters.isEmpty()) { return false; // this should never happen, but check just to be sure } diff --git a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java index 0d11622212..d4252f6548 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java @@ -28,6 +28,8 @@ import android.view.View; import android.widget.TextView; +import org.schabi.newpipe.extractor.search.filter.FilterItem; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.text.HtmlCompat; @@ -78,8 +80,8 @@ private static void checkServiceId(final int serviceId) { } public static Single searchFor(final int serviceId, final String searchString, - final List contentFilter, - final String sortFilter) { + final List contentFilter, + final List sortFilter) { checkServiceId(serviceId); return Single.fromCallable(() -> SearchInfo.getInfo(NewPipe.getService(serviceId), @@ -91,8 +93,8 @@ public static Single searchFor(final int serviceId, final String sea public static Single> getMoreSearchItems( final int serviceId, final String searchString, - final List contentFilter, - final String sortFilter, + final List contentFilter, + final List sortFilter, final Page page) { checkServiceId(serviceId); return Single.fromCallable(() -> diff --git a/app/src/main/java/org/schabi/newpipe/util/KioskTranslator.java b/app/src/main/java/org/schabi/newpipe/util/KioskTranslator.java index b8c2ff2369..c271aa2ef7 100644 --- a/app/src/main/java/org/schabi/newpipe/util/KioskTranslator.java +++ b/app/src/main/java/org/schabi/newpipe/util/KioskTranslator.java @@ -59,6 +59,10 @@ public static String getTranslatedKioskName(final String kioskId, final Context public static int getKioskIcon(final String kioskId) { switch (kioskId) { + case "Trending Today": + case "Trending This Week": + case "Trending This Month": + case "Recommended Channels": case "Trending": case "Top 50": case "New & hot": @@ -78,7 +82,7 @@ public static int getKioskIcon(final String kioskId) { case "Radio": return R.drawable.ic_radio; default: - return 0; + return R.drawable.ic_stars; } } } diff --git a/app/src/main/java/org/schabi/newpipe/util/ReleaseVersionUtil.kt b/app/src/main/java/org/schabi/newpipe/util/ReleaseVersionUtil.kt index 3ea19fa4f8..1c315c694f 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ReleaseVersionUtil.kt +++ b/app/src/main/java/org/schabi/newpipe/util/ReleaseVersionUtil.kt @@ -13,7 +13,7 @@ import java.time.format.DateTimeFormatter object ReleaseVersionUtil { // Public key of the certificate that is used in NewPipe release versions private const val RELEASE_CERT_PUBLIC_KEY_SHA256 = - "cb84069bd68116bafae5ee4ee5b08a567aa6d898404e7cb12f9e756df5cf5cab" + "2f0c31d07f701416b2943376491cb16ebb718156defc2b1269aac04b94396c85" @OptIn(ExperimentalStdlibApi::class) val isReleaseApk by lazy { diff --git a/app/src/main/java/org/schabi/newpipe/util/ReturnYouTubeDislikeUtils.java b/app/src/main/java/org/schabi/newpipe/util/ReturnYouTubeDislikeUtils.java new file mode 100644 index 0000000000..d95e53f022 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/ReturnYouTubeDislikeUtils.java @@ -0,0 +1,68 @@ +package org.schabi.newpipe.util; + +import android.content.Context; +import android.content.SharedPreferences; +import android.util.Log; + +import androidx.preference.PreferenceManager; + +import com.grack.nanojson.JsonObject; +import com.grack.nanojson.JsonParser; + +import org.schabi.newpipe.MainActivity; +import org.schabi.newpipe.R; +import org.schabi.newpipe.BraveTimeoutInterceptor; +import org.schabi.newpipe.extractor.ServiceList; +import org.schabi.newpipe.extractor.stream.StreamInfo; + +public final class ReturnYouTubeDislikeUtils { + + private static final String API_URL = "https://returnyoutubedislikeapi.com/votes?videoId="; + private static final String TAG = ReturnYouTubeDislikeUtils.class.getSimpleName(); + private static final boolean DEBUG = MainActivity.DEBUG; + + private ReturnYouTubeDislikeUtils() { + } + + @SuppressWarnings("CheckStyle") + public static int getDislikes(final Context context, + final StreamInfo streamInfo) { + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + + final boolean isReturnYouTubeDislikeEnabled = prefs.getBoolean(context + .getString(R.string.enable_return_youtube_dislike_key), false); + + if (!isReturnYouTubeDislikeEnabled) { + return -1; + } + + if (streamInfo.getServiceId() != ServiceList.YouTube.getServiceId()) { + return -1; + } + + JsonObject response = null; + + try { + final String responseBody = BraveTimeoutInterceptor + .get(API_URL + streamInfo.getId(), 3) + .responseBody(); + + response = JsonParser.object().from(responseBody); + + } catch (final Exception ex) { + if (DEBUG) { + Log.w(TAG, Log.getStackTraceString(ex)); + } + } + + if (response == null) { + return -1; + } + + if (response.has("dislikes")) { + return response.getInt("dislikes", 0); + } + + return -1; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.java b/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.java index c712157b35..379a445233 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.java @@ -1,16 +1,8 @@ package org.schabi.newpipe.util; -import static org.schabi.newpipe.extractor.ServiceList.SoundCloud; - import android.content.Context; import android.content.SharedPreferences; -import androidx.annotation.DrawableRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.StringRes; -import androidx.preference.PreferenceManager; - import com.grack.nanojson.JsonObject; import com.grack.nanojson.JsonParser; import com.grack.nanojson.JsonParserException; @@ -20,15 +12,202 @@ import org.schabi.newpipe.extractor.ServiceList; import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.extractor.search.filter.LibraryStringIds; import org.schabi.newpipe.extractor.services.peertube.PeertubeInstance; +import java.util.EnumMap; +import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.concurrent.TimeUnit; +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.preference.PreferenceManager; + +import static org.schabi.newpipe.extractor.ServiceList.SoundCloud; + public final class ServiceHelper { private static final StreamingService DEFAULT_FALLBACK_SERVICE = ServiceList.YouTube; + /** + * Map all available {@link LibraryStringIds} ids to resource ids available in strings.xml. + */ + private static final Map LIBRARY_STRING_ID_TO_RES_ID_MAP = + new EnumMap<>(LibraryStringIds.class); + + static { + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_10_30_MIN, + R.string.search_filters_10_30_min); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_2_10_MIN, + R.string.search_filters_2_10_min); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_360, + R.string.search_filters_360); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_3D, + R.string.search_filters_3d); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_4_20_MIN, + R.string.search_filters_4_20_min); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_4K, + R.string.search_filters_4k); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_ADDED, + R.string.search_filters_added); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_ALBUMS, + R.string.albums); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_ANY_TIME, + R.string.search_filters_any_time); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_ALL, + R.string.all); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_ARTISTS_AND_LABELS, + R.string.search_filters_artists_and_labels); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_ASCENDING, + R.string.search_filters_ascending); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_CCOMMONS, + R.string.search_filters_ccommons); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_CHANNELS, + R.string.channels); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_CONFERENCES, + R.string.conferences); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_CREATION_DATE, + R.string.search_filters_creation_date); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_DATE, + R.string.search_filters_date); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_DURATION, + R.string.search_filters_duration); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_EVENTS, + R.string.events); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_FEATURES, + R.string.search_filters_features); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_GREATER_30_MIN, + R.string.search_filters_greater_30_min); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_HD, + R.string.search_filters_hd); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_HDR, + R.string.search_filters_hdr); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_KIND, + R.string.search_filters_kind); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_LAST_30_DAYS, + R.string.search_filters_last_30_days); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_LAST_7_DAYS, + R.string.search_filters_last_7_days); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_LAST_HOUR, + R.string.search_filters_last_hour); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_LAST_YEAR, + R.string.search_filters_last_year); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_LENGTH, + R.string.search_filters_length); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_LESS_2_MIN, + R.string.search_filters_less_2_min); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_LICENSE, + R.string.search_filters_license); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_LIKES, + R.string.detail_likes_img_view_description); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_LIVE, + R.string.duration_live); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_LOCATION, + R.string.search_filters_location); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_LONG_GREATER_10_MIN, + R.string.search_filters_long_greater_10_min); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_MEDIUM_4_10_MIN, + R.string.search_filters_medium_4_10_min); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_THIS_MONTH, + R.string.search_filters_this_month); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_ARTISTS, + R.string.artists); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_SONGS, + R.string.songs); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_NAME, + R.string.name); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_NO, + R.string.search_filters_no); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_OVER_20_MIN, + R.string.search_filters_over_20_min); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_PAST_DAY, + R.string.search_filters_past_day); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_PAST_HOUR, + R.string.search_filters_past_hour); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_PAST_MONTH, + R.string.search_filters_past_month); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_PAST_WEEK, + R.string.search_filters_past_week); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_PAST_YEAR, + R.string.search_filters_past_year); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_PLAYLISTS, + R.string.playlists); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_PUBLISH_DATE, + R.string.search_filters_publish_date); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_PUBLISHED, + R.string.search_filters_published); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_PURCHASED, + R.string.search_filters_published); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_RATING, + R.string.search_filters_rating); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_RELEVANCE, + R.string.search_filters_relevance); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_SENSITIVE, + R.string.search_filters_sensitive); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_SEPIASEARCH, + R.string.search_filters_sepiasearch); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_SHORT_LESS_4_MIN, + R.string.search_filters_short_less_4_min); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_SORT_BY, + R.string.search_filters_sort_by); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_SORT_ORDER, + R.string.search_filters_sort_order); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_SUBTITLES, + R.string.search_filters_subtitles); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_TO_MODIFY_COMMERCIALLY, + R.string.search_filters_to_modify_commercially); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_TODAY, + R.string.search_filters_today); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_TRACKS, + R.string.tracks); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_UNDER_4_MIN, + R.string.search_filters_under_4_min); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_UPLOAD_DATE, + R.string.search_filters_upload_date); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_USERS, + R.string.users); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_VIDEOS, + R.string.videos_string); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_VIEWS, + R.string.search_filters_views); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_VOD_VIDEOS, + R.string.search_filters_vod_videos); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_VR180, + R.string.search_filters_vr180); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_THIS_WEEK, + R.string.search_filters_this_week); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_THIS_YEAR, + R.string.search_filters_this_year); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_YES, + R.string.search_filters_yes); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_YOUTUBE_MUSIC, + R.string.search_filters_youtube_music); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_SHORT, + R.string.search_filters_short /* Short */); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_LONG, + R.string.search_filters_long /* Long */); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_RUMBLES, + R.string.search_filters_rumbles /* Rumbles */); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_MOST_RECENT, + R.string.search_filters_most_recent /* Most recent */); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_SHORT_0_5M, + R.string.search_filters_short_0_5_min /* Short (0-5m) */); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_MEDIUM_5_20M, + R.string.search_filters_medium_5_20_min /* Medium (5-20m) */); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_LONG_20M_PLUS, + R.string.search_filters_long_20_min_plus /* Long (20m+) */); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_FEATURE_45M_PLUS, + R.string.search_filters_feature_45_min_plus /* Feature (45m+) */); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_NEWEST_FIRST, + R.string.search_filters_newest_first /* Newest First */); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_OLDEST_FIRST, + R.string.search_filters_oldest_first /* Oldest First */); + } - private ServiceHelper() { } + private ServiceHelper() { + } @DrawableRes public static int getIcon(final int serviceId) { @@ -43,43 +222,15 @@ public static int getIcon(final int serviceId) { return R.drawable.ic_placeholder_peertube; case 4: return R.drawable.ic_placeholder_bandcamp; + case 5: + return R.drawable.ic_placeholder_bitchute; + case 6: + return R.drawable.ic_placeholder_rumble; default: return R.drawable.ic_circle; } } - public static String getTranslatedFilterString(final String filter, final Context c) { - switch (filter) { - case "all": - return c.getString(R.string.all); - case "videos": - case "sepia_videos": - case "music_videos": - return c.getString(R.string.videos_string); - case "channels": - return c.getString(R.string.channels); - case "playlists": - case "music_playlists": - return c.getString(R.string.playlists); - case "tracks": - return c.getString(R.string.tracks); - case "users": - return c.getString(R.string.users); - case "conferences": - return c.getString(R.string.conferences); - case "events": - return c.getString(R.string.events); - case "music_songs": - return c.getString(R.string.songs); - case "music_albums": - return c.getString(R.string.albums); - case "music_artists": - return c.getString(R.string.artists); - default: - return filter; - } - } - /** * Get a resource string with instructions for importing subscriptions for each service. * @@ -107,12 +258,10 @@ public static int getImportInstructions(final int serviceId) { */ @StringRes public static int getImportInstructionsHint(final int serviceId) { - switch (serviceId) { - case 1: - return R.string.import_soundcloud_instructions_hint; - default: - return -1; + if (serviceId == 1) { + return R.string.import_soundcloud_instructions_hint; } + return -1; } public static int getSelectedServiceId(final Context context) { @@ -210,4 +359,14 @@ public static void initServices(final Context context) { initService(context, s.getServiceId()); } } + + public static String getTranslatedFilterString(@NonNull final LibraryStringIds stringId, + @NonNull final Context context) { + if (LIBRARY_STRING_ID_TO_RES_ID_MAP.containsKey(stringId)) { + return context.getString( + Objects.requireNonNull(LIBRARY_STRING_ID_TO_RES_ID_MAP.get(stringId))); + } else { + return stringId.toString(); + } + } } diff --git a/app/src/main/java/org/schabi/newpipe/util/SponsorBlockUtils.java b/app/src/main/java/org/schabi/newpipe/util/SponsorBlockUtils.java new file mode 100644 index 0000000000..02d3bad369 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/SponsorBlockUtils.java @@ -0,0 +1,352 @@ +package org.schabi.newpipe.util; + +import android.app.Application; +import android.content.Context; +import android.content.SharedPreferences; +import android.graphics.Color; +import android.net.ConnectivityManager; +import android.text.TextUtils; +import android.util.Log; + +import androidx.preference.PreferenceManager; + +import com.grack.nanojson.JsonArray; +import com.grack.nanojson.JsonObject; +import com.grack.nanojson.JsonParser; + +import org.schabi.newpipe.App; +import org.schabi.newpipe.MainActivity; +import org.schabi.newpipe.R; +import org.schabi.newpipe.BraveTimeoutInterceptor; +import org.schabi.newpipe.extractor.ServiceList; +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.player.playqueue.PlayQueueItem; +import org.schabi.newpipe.views.MarkableSeekBar; +import org.schabi.newpipe.views.SeekBarMarker; + +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; + +public final class SponsorBlockUtils { + private static final Application APP = App.getApp(); + private static final String TAG = SponsorBlockUtils.class.getSimpleName(); + private static final boolean DEBUG = MainActivity.DEBUG; + private static Map videoSegmentsCache = new HashMap<>(); + + private SponsorBlockUtils() { + } + + @SuppressWarnings("CheckStyle") + public static VideoSegment[] getYouTubeVideoSegments(final Context context, + final StreamInfo streamInfo) + throws UnsupportedEncodingException { + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + + final boolean isSponsorBlockEnabled = prefs.getBoolean(context + .getString(R.string.sponsor_block_enable_key), false); + + if (!isSponsorBlockEnabled) { + return null; + } + + final String apiUrl = prefs.getString(context + .getString(R.string.sponsor_block_api_url_key), null); + + if (streamInfo.getServiceId() != ServiceList.YouTube.getServiceId() + || apiUrl == null + || apiUrl.isEmpty()) { + return null; + } + + final boolean includeSponsorCategory = prefs.getBoolean(context + .getString(R.string.sponsor_block_category_sponsor_key), false); + final boolean includeIntroCategory = prefs.getBoolean(context + .getString(R.string.sponsor_block_category_intro_key), false); + final boolean includeOutroCategory = prefs.getBoolean(context + .getString(R.string.sponsor_block_category_outro_key), false); + final boolean includeInteractionCategory = prefs.getBoolean(context + .getString(R.string.sponsor_block_category_interaction_key), false); + final boolean includeSelfPromoCategory = prefs.getBoolean(context + .getString(R.string.sponsor_block_category_self_promo_key), false); + final boolean includeMusicCategory = prefs.getBoolean(context + .getString(R.string.sponsor_block_category_non_music_key), false); + final boolean includePreviewCategory = prefs.getBoolean(context + .getString(R.string.sponsor_block_category_preview_key), false); + final boolean includeFillerCategory = prefs.getBoolean(context + .getString(R.string.sponsor_block_category_filler_key), false); + + final ArrayList categoryParamList = new ArrayList<>(); + + if (includeSponsorCategory) { + categoryParamList.add("sponsor"); + } + if (includeIntroCategory) { + categoryParamList.add("intro"); + } + if (includeOutroCategory) { + categoryParamList.add("outro"); + } + if (includeInteractionCategory) { + categoryParamList.add("interaction"); + } + if (includeSelfPromoCategory) { + categoryParamList.add("selfpromo"); + } + if (includeMusicCategory) { + categoryParamList.add("music_offtopic"); + } + if (includePreviewCategory) { + categoryParamList.add("preview"); + } + + if (includeFillerCategory) { + categoryParamList.add("filler"); + } + + if (categoryParamList.size() == 0) { + return null; + } + + String categoryParams = "[\"" + TextUtils.join("\",\"", categoryParamList) + "\"]"; + categoryParams = URLEncoder.encode(categoryParams, "utf-8"); + + final String videoIdHash = toSha256(streamInfo.getId()); + + if (videoIdHash == null) { + return null; + } + + final String params = "skipSegments/" + videoIdHash.substring(0, 4) + + "?categories=" + categoryParams; + + final VideoSegment[] alreadyFetchedVideoSegments = videoSegmentsCache.get(params); + if (alreadyFetchedVideoSegments != null) { + return alreadyFetchedVideoSegments; + } + + if (!isConnected()) { + return null; + } + + JsonArray responseArray = null; + + try { + final String responseBody = BraveTimeoutInterceptor + .get(apiUrl + params, 3) + .responseBody(); + + + responseArray = JsonParser.array().from(responseBody); + + } catch (final Exception ex) { + if (DEBUG) { + Log.w(TAG, Log.getStackTraceString(ex)); + } + } + + if (responseArray == null) { + return null; + } + + final ArrayList result = new ArrayList<>(); + + for (final Object obj1 : responseArray) { + final JsonObject jObj1 = (JsonObject) obj1; + + final String responseVideoId = jObj1.getString("videoID"); + if (!responseVideoId.equals(streamInfo.getId())) { + continue; + } + + final JsonArray segmentArray = (JsonArray) jObj1.get("segments"); + if (segmentArray == null) { + continue; + } + + for (final Object obj2 : segmentArray) { + final JsonObject jObj2 = (JsonObject) obj2; + + final JsonArray segmentInfo = (JsonArray) jObj2.get("segment"); + if (segmentInfo == null) { + continue; + } + + final double startTime = segmentInfo.getDouble(0) * 1000; + final double endTime = segmentInfo.getDouble(1) * 1000; + final String category = jObj2.getString("category"); + + final VideoSegment segment = new VideoSegment(startTime, endTime, category); + result.add(segment); + } + } + final VideoSegment[] segments = result.toArray(new VideoSegment[0]); + videoSegmentsCache.put(params, segments); + return segments; + } + + private static boolean isConnected() { + final ConnectivityManager cm = + (ConnectivityManager) APP.getSystemService(Context.CONNECTIVITY_SERVICE); + return cm.getActiveNetworkInfo() != null + && cm.getActiveNetworkInfo().isConnected(); + } + + private static String toSha256(final String videoId) { + try { + final MessageDigest digest = MessageDigest.getInstance("SHA-256"); + final byte[] bytes = digest.digest(videoId.getBytes(StandardCharsets.UTF_8)); + final StringBuilder sb = new StringBuilder(); + + for (final byte b : bytes) { + final String hex = Integer.toHexString(0xff & b); + + if (hex.length() == 1) { + sb.append('0'); + } + + sb.append(hex); + } + + return sb.toString(); + } catch (final Exception e) { + Log.e("SPONSOR_BLOCK", "Error getting video ID hash.", e); + return null; + } + } + + static Integer parseSegmentCategory( + final String category, + final Context context, + final SharedPreferences prefs + ) { + String key; + final String colorStr; + switch (category) { + case "sponsor": + key = context.getString(R.string.sponsor_block_category_sponsor_key); + if (prefs.getBoolean(key, false)) { + key = context.getString(R.string.sponsor_block_category_sponsor_color_key); + colorStr = prefs.getString(key, null); + return colorStr == null + ? context.getResources().getColor(R.color.sponsor_segment) + : Color.parseColor(colorStr); + } + break; + case "intro": + key = context.getString(R.string.sponsor_block_category_intro_key); + if (prefs.getBoolean(key, false)) { + key = context.getString(R.string.sponsor_block_category_intro_color_key); + colorStr = prefs.getString(key, null); + return colorStr == null + ? context.getResources().getColor(R.color.intro_segment) + : Color.parseColor(colorStr); + } + break; + case "outro": + key = context.getString(R.string.sponsor_block_category_outro_key); + if (prefs.getBoolean(key, false)) { + key = context.getString(R.string.sponsor_block_category_outro_color_key); + colorStr = prefs.getString(key, null); + return colorStr == null + ? context.getResources().getColor(R.color.outro_segment) + : Color.parseColor(colorStr); + } + break; + case "interaction": + key = context.getString(R.string.sponsor_block_category_interaction_key); + if (prefs.getBoolean(key, false)) { + key = context.getString(R.string.sponsor_block_category_interaction_color_key); + colorStr = prefs.getString(key, null); + return colorStr == null + ? context.getResources().getColor(R.color.interaction_segment) + : Color.parseColor(colorStr); + } + break; + case "selfpromo": + key = context.getString(R.string.sponsor_block_category_self_promo_key); + if (prefs.getBoolean(key, false)) { + key = context.getString(R.string.sponsor_block_category_self_promo_color_key); + colorStr = prefs.getString(key, null); + return colorStr == null + ? context.getResources().getColor(R.color.self_promo_segment) + : Color.parseColor(colorStr); + } + break; + case "music_offtopic": + key = context.getString(R.string.sponsor_block_category_non_music_key); + if (prefs.getBoolean(key, false)) { + key = context.getString(R.string.sponsor_block_category_non_music_color_key); + colorStr = prefs.getString(key, null); + return colorStr == null + ? context.getResources().getColor(R.color.non_music_segment) + : Color.parseColor(colorStr); + } + break; + case "preview": + key = context.getString(R.string.sponsor_block_category_preview_key); + if (prefs.getBoolean(key, false)) { + key = context.getString(R.string.sponsor_block_category_preview_color_key); + colorStr = prefs.getString(key, null); + return colorStr == null + ? context.getResources().getColor(R.color.preview_segment) + : Color.parseColor(colorStr); + } + break; + case "filler": + key = context.getString(R.string.sponsor_block_category_filler_key); + if (prefs.getBoolean(key, false)) { + key = context.getString(R.string.sponsor_block_category_filler_color_key); + colorStr = prefs.getString(key, null); + return colorStr == null + ? context.getResources().getColor(R.color.filler_segment) + : Color.parseColor(colorStr); + } + break; + } + + return null; + } + + public static void markSegments( + final PlayQueueItem currentItem, + final MarkableSeekBar seekBar, + final Context context, + final SharedPreferences prefs + ) { + seekBar.clearMarkers(); + + if (currentItem == null) { + return; + } + + final VideoSegment[] segments = currentItem.getVideoSegments(); + + if (segments == null || segments.length == 0) { + return; + } + + for (final VideoSegment segment : segments) { + final Integer color = parseSegmentCategory(segment.category, context, prefs); + + // if null, then this category should not be marked + if (color == null) { + continue; + } + + // Duration is in seconds, we need millis + final int length = (int) currentItem.getDuration() * 1000; + + final SeekBarMarker seekBarMarker = + new SeekBarMarker(segment.startTime, segment.endTime, + length, color); + seekBar.seekBarMarkers.add(seekBarMarker); + } + + seekBar.drawMarkers(); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/VideoSegment.java b/app/src/main/java/org/schabi/newpipe/util/VideoSegment.java new file mode 100644 index 0000000000..5fdad49ef4 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/VideoSegment.java @@ -0,0 +1,15 @@ +package org.schabi.newpipe.util; + +import java.io.Serializable; + +public class VideoSegment implements Serializable { + public double startTime; + public double endTime; + public String category; + + public VideoSegment(final double startTime, final double endTime, final String category) { + this.startTime = startTime; + this.endTime = endTime; + this.category = category; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/image/PicassoHelper.java b/app/src/main/java/org/schabi/newpipe/util/image/PicassoHelper.java index 86a839405f..9526ab97c3 100644 --- a/app/src/main/java/org/schabi/newpipe/util/image/PicassoHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/image/PicassoHelper.java @@ -6,6 +6,7 @@ import android.annotation.SuppressLint; import android.content.Context; +import android.content.SharedPreferences; import android.graphics.Bitmap; import android.util.Log; @@ -21,6 +22,9 @@ import com.squareup.picasso.RequestCreator; import com.squareup.picasso.Transformation; +import org.schabi.newpipe.BraveDownloaderImplUtils; +import org.schabi.newpipe.BraveTimeoutInterceptor; +import org.schabi.newpipe.DownloaderImpl; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.Image; @@ -29,6 +33,7 @@ import java.util.List; import java.util.concurrent.TimeUnit; +import androidx.preference.PreferenceManager; import okhttp3.OkHttpClient; public final class PicassoHelper { @@ -49,12 +54,28 @@ private PicassoHelper() { public static void init(final Context context) { picassoCache = new LruCache(10 * 1024 * 1024); - picassoDownloaderClient = new OkHttpClient.Builder() - .cache(new okhttp3.Cache(new File(context.getExternalCacheDir(), "picasso"), + + final OkHttpClient.Builder builder = DownloaderImpl.getInstance().getNewBuilder(); + builder.cache(new okhttp3.Cache(new File(context.getExternalCacheDir(), "picasso"), 50L * 1024L * 1024L)) // this should already be the default timeout in OkHttp3, but just to be sure... - .callTimeout(15, TimeUnit.SECONDS) - .build(); + .callTimeout(15, TimeUnit.SECONDS); + + initInternal(context, builder); + } + + public static void reInit(final Context context) { + + final OkHttpClient.Builder builder = picassoDownloaderClient.newBuilder(); + initInternal(context, builder); + } + + private static void initInternal( + final Context context, + final OkHttpClient.Builder builder) { + addBraveHostInterceptor(context, builder); + + picassoDownloaderClient = builder.build(); picassoInstance = new Picasso.Builder(context) .memoryCache(picassoCache) // memory cache @@ -63,6 +84,14 @@ public static void init(final Context context) { .build(); } + private static void addBraveHostInterceptor( + final Context context, + final OkHttpClient.Builder builder) { + builder.interceptors().removeIf(BraveTimeoutInterceptor.class::isInstance); + final SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(context); + BraveDownloaderImplUtils.addOrRemoveHostInterceptor(builder, context, settings); + } + public static void terminate() { picassoCache = null; picassoDownloaderClient = null; diff --git a/app/src/main/java/us/shandian/giga/get/DownloadInitializer.java b/app/src/main/java/us/shandian/giga/get/DownloadInitializer.java index 84e968b43b..76718998b1 100644 --- a/app/src/main/java/us/shandian/giga/get/DownloadInitializer.java +++ b/app/src/main/java/us/shandian/giga/get/DownloadInitializer.java @@ -12,10 +12,12 @@ import java.net.HttpURLConnection; import java.nio.channels.ClosedByInterruptException; +import androidx.annotation.Nullable; import us.shandian.giga.util.Utility; import static org.schabi.newpipe.BuildConfig.DEBUG; import static us.shandian.giga.get.DownloadMission.ERROR_HTTP_FORBIDDEN; +import static us.shandian.giga.get.DownloadMission.ERROR_HTTP_METHOD_NOT_ALLOWED; public class DownloadInitializer extends Thread { private static final String TAG = "DownloadInitializer"; @@ -33,7 +35,9 @@ public class DownloadInitializer extends Thread { private void dispose() { try { - mConn.getInputStream().close(); + if (mConn != null) { + mConn.getInputStream().close(); + } } catch (Exception e) { // nothing to do } @@ -45,6 +49,7 @@ public void run() { int retryCount = 0; int httpCode = 204; + boolean headRequest = true; while (true) { try { @@ -54,9 +59,7 @@ public void run() { long lowestSize = Long.MAX_VALUE; for (int i = 0; i < mMission.urls.length && mMission.running; i++) { - mConn = mMission.openConnection(mMission.urls[i], true, 0, 0); - mMission.establishConnection(mId, mConn); - dispose(); + headRequest = httpSession(mMission.urls[i], headRequest, 0, 0); if (Thread.interrupted()) return; long length = Utility.getTotalContentLength(mConn); @@ -84,9 +87,7 @@ public void run() { } } else { // ask for the current resource length - mConn = mMission.openConnection(true, 0, 0); - mMission.establishConnection(mId, mConn); - dispose(); + headRequest = httpSession(null, headRequest, 0, 0); if (!mMission.running || Thread.interrupted()) return; @@ -110,9 +111,7 @@ public void run() { } } else { // Open again - mConn = mMission.openConnection(true, mMission.length - 10, mMission.length); - mMission.establishConnection(mId, mConn); - dispose(); + headRequest = httpSession(null, headRequest, mMission.length - 10, mMission.length); if (!mMission.running || Thread.interrupted()) return; @@ -203,6 +202,43 @@ public void run() { @Override public void interrupt() { super.interrupt(); - if (mConn != null) dispose(); + dispose(); + } + + // the url parameter can be null if method was called the first time with an url + private void openEstablishAndCloseConnectionAfterwards( + @Nullable final String url, final boolean headRequest, + final long rangeStart, final long rangeEnd) + throws IOException, DownloadMission.HttpError { + if (url != null) { + mConn = mMission.openConnection(url, headRequest, rangeStart, rangeEnd); + } else { + mConn = mMission.openConnection(headRequest, rangeStart, rangeEnd); + } + mMission.establishConnection(mId, mConn); + dispose(); + } + + // connect ot http server and disconnect afterwards + private boolean httpSession( + @Nullable final String url, final boolean headRequest, + final long rangeStart, final long rangeEnd) + throws IOException, DownloadMission.HttpError { + boolean hasHeadMethod = headRequest; + try { + openEstablishAndCloseConnectionAfterwards(url, hasHeadMethod, rangeStart, rangeEnd); + } catch (final Exception e) { + if (e instanceof DownloadMission.HttpError + && ((DownloadMission.HttpError) e).statusCode == ERROR_HTTP_METHOD_NOT_ALLOWED) { + // fallback to GET as HEAD request method is probably not allowed + // available for this service. + // -> Rumble is known for not supporting HEAD anymore (discovered 20230203) + hasHeadMethod = false; + openEstablishAndCloseConnectionAfterwards(url, hasHeadMethod, rangeStart, rangeEnd); + } else { + throw e; + } + } + return hasHeadMethod; } } diff --git a/app/src/main/java/us/shandian/giga/get/DownloadMission.java b/app/src/main/java/us/shandian/giga/get/DownloadMission.java index 04930b002d..4661e1ce03 100644 --- a/app/src/main/java/us/shandian/giga/get/DownloadMission.java +++ b/app/src/main/java/us/shandian/giga/get/DownloadMission.java @@ -57,6 +57,7 @@ public class DownloadMission extends Mission { public static final int ERROR_RESOURCE_GONE = 1013; public static final int ERROR_HTTP_NO_CONTENT = 204; static final int ERROR_HTTP_FORBIDDEN = 403; + static final int ERROR_HTTP_METHOD_NOT_ALLOWED = 405; /** * The urls of the file to download diff --git a/app/src/main/java/us/shandian/giga/get/FinishedMission.java b/app/src/main/java/us/shandian/giga/get/FinishedMission.java index 29f3c62968..84450a96b0 100644 --- a/app/src/main/java/us/shandian/giga/get/FinishedMission.java +++ b/app/src/main/java/us/shandian/giga/get/FinishedMission.java @@ -13,6 +13,7 @@ public FinishedMission(@NonNull DownloadMission mission) { timestamp = mission.timestamp; kind = mission.kind; storage = mission.storage; + segmentsJson = mission.segmentsJson; } } diff --git a/app/src/main/java/us/shandian/giga/get/Mission.java b/app/src/main/java/us/shandian/giga/get/Mission.java index 77b9c1e339..fbb8b10c88 100644 --- a/app/src/main/java/us/shandian/giga/get/Mission.java +++ b/app/src/main/java/us/shandian/giga/get/Mission.java @@ -2,6 +2,8 @@ import androidx.annotation.NonNull; +import org.schabi.newpipe.util.VideoSegment; + import java.io.Serializable; import java.util.Calendar; @@ -39,6 +41,8 @@ public long getTimestamp() { */ public StoredFileHelper storage; + public String segmentsJson; + /** * Delete the downloaded file * diff --git a/app/src/main/java/us/shandian/giga/get/sqlite/FinishedMissionStore.java b/app/src/main/java/us/shandian/giga/get/sqlite/FinishedMissionStore.java index 704385212a..8827b83914 100644 --- a/app/src/main/java/us/shandian/giga/get/sqlite/FinishedMissionStore.java +++ b/app/src/main/java/us/shandian/giga/get/sqlite/FinishedMissionStore.java @@ -27,7 +27,7 @@ public class FinishedMissionStore extends SQLiteOpenHelper { // TODO: use NewPipeSQLiteHelper ('s constants) when playlist branch is merged (?) private static final String DATABASE_NAME = "downloads.db"; - private static final int DATABASE_VERSION = 4; + private static final int DATABASE_VERSION = 5; /** * The table name of download missions (old) @@ -56,6 +56,8 @@ public class FinishedMissionStore extends SQLiteOpenHelper { private static final String KEY_PATH = "path"; + private static final String KEY_SEGMENTS = "segments"; + /** * The statement to create the table */ @@ -66,6 +68,7 @@ public class FinishedMissionStore extends SQLiteOpenHelper { KEY_DONE + " INTEGER NOT NULL, " + KEY_TIMESTAMP + " INTEGER NOT NULL, " + KEY_KIND + " TEXT NOT NULL, " + + KEY_SEGMENTS + " TEXT, " + " UNIQUE(" + KEY_TIMESTAMP + ", " + KEY_PATH + "));"; @@ -121,6 +124,11 @@ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { cursor.close(); db.execSQL("DROP TABLE " + MISSIONS_TABLE_NAME_v2); + oldVersion++; + } + + if (oldVersion == 4) { + db.execSQL("ALTER TABLE " + FINISHED_TABLE_NAME + " ADD COLUMN " + KEY_SEGMENTS + " TEXT;"); } } @@ -137,6 +145,7 @@ private ContentValues getValuesOfMission(@NonNull Mission downloadMission) { values.put(KEY_DONE, downloadMission.length); values.put(KEY_TIMESTAMP, downloadMission.timestamp); values.put(KEY_KIND, String.valueOf(downloadMission.kind)); + values.put(KEY_SEGMENTS, downloadMission.segmentsJson); return values; } @@ -152,6 +161,7 @@ private FinishedMission getMissionFromCursor(Cursor cursor) { mission.length = cursor.getLong(cursor.getColumnIndexOrThrow(KEY_DONE)); mission.timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(KEY_TIMESTAMP)); mission.kind = kind.charAt(0); + mission.segmentsJson = cursor.getString(cursor.getColumnIndexOrThrow(KEY_SEGMENTS)); try { mission.storage = new StoredFileHelper(context,null, Uri.parse(path), ""); diff --git a/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java b/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java index 45211211f4..6b57c36f89 100755 --- a/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java +++ b/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java @@ -38,12 +38,16 @@ import androidx.core.content.IntentCompat; import androidx.preference.PreferenceManager; +import com.grack.nanojson.JsonStringWriter; +import com.grack.nanojson.JsonWriter; + import org.schabi.newpipe.R; import org.schabi.newpipe.download.DownloadActivity; import org.schabi.newpipe.player.helper.LockManager; import org.schabi.newpipe.streams.io.StoredDirectoryHelper; import org.schabi.newpipe.streams.io.StoredFileHelper; import org.schabi.newpipe.util.Localization; +import org.schabi.newpipe.util.VideoSegment; import java.io.File; import java.io.IOException; @@ -80,6 +84,7 @@ public class DownloadManagerService extends Service { private static final String EXTRA_PARENT_PATH = "DownloadManagerService.extra.storageParentPath"; private static final String EXTRA_STORAGE_TAG = "DownloadManagerService.extra.storageTag"; private static final String EXTRA_RECOVERY_INFO = "DownloadManagerService.extra.recoveryInfo"; + private static final String EXTRA_SEGMENTS = "DownloadManagerService.extra.segments"; private static final String ACTION_RESET_DOWNLOAD_FINISHED = APPLICATION_ID + ".reset_download_finished"; private static final String ACTION_OPEN_DOWNLOADS_FINISHED = APPLICATION_ID + ".open_downloads_finished"; @@ -361,7 +366,8 @@ public void updateForegroundState(boolean state) { public static void startMission(Context context, String[] urls, StoredFileHelper storage, char kind, int threads, String source, String psName, String[] psArgs, long nearLength, - ArrayList recoveryInfo) { + ArrayList recoveryInfo, + VideoSegment[] segments) { final Intent intent = new Intent(context, DownloadManagerService.class) .setAction(Intent.ACTION_RUN) .putExtra(EXTRA_URLS, urls) @@ -374,6 +380,7 @@ public static void startMission(Context context, String[] urls, StoredFileHelper .putExtra(EXTRA_RECOVERY_INFO, recoveryInfo) .putExtra(EXTRA_PARENT_PATH, storage.getParentUri()) .putExtra(EXTRA_PATH, storage.getUri()) + .putExtra(EXTRA_SEGMENTS, segments) .putExtra(EXTRA_STORAGE_TAG, storage.getTag()); context.startService(intent); @@ -394,6 +401,8 @@ private void startMission(Intent intent) { MissionRecoveryInfo.class); Objects.requireNonNull(recovery); + VideoSegment[] segments = (VideoSegment[]) intent.getSerializableExtra(EXTRA_SEGMENTS); + StoredFileHelper storage; try { storage = new StoredFileHelper(this, parentPath, path, tag); @@ -413,6 +422,25 @@ private void startMission(Intent intent) { mission.nearLength = nearLength; mission.recoveryInfo = recovery.toArray(new MissionRecoveryInfo[0]); + if (segments != null && segments.length > 0) { + try { + final JsonStringWriter writer = JsonWriter.string() + .object() + .array("segments"); + for (final VideoSegment segment : segments) { + writer.object() + .value("start", segment.startTime) + .value("end", segment.endTime) + .value("category", segment.category) + .end(); + } + writer.end().end(); + mission.segmentsJson = writer.done(); + } catch (final Exception e) { + e.printStackTrace(); + } + } + if (ps != null) ps.setTemporalDir(DownloadManager.pickAvailableTemporalDir(this)); diff --git a/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java b/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java index c6840702b6..b86907d1b1 100644 --- a/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java +++ b/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java @@ -1,5 +1,6 @@ package us.shandian.giga.ui.adapter; +import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK; import static android.content.Intent.FLAG_GRANT_PREFIX_URI_PERMISSION; import static android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION; import static us.shandian.giga.get.DownloadMission.ERROR_CONNECT_HOST; @@ -23,6 +24,7 @@ import android.app.NotificationManager; import android.content.Context; import android.content.Intent; +import android.content.SharedPreferences; import android.graphics.Color; import android.net.Uri; import android.os.Build; @@ -48,6 +50,7 @@ import androidx.core.content.ContextCompat; import androidx.core.content.FileProvider; import androidx.core.os.HandlerCompat; +import androidx.preference.PreferenceManager; import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView.Adapter; @@ -55,7 +58,9 @@ import com.google.android.material.snackbar.Snackbar; +import org.schabi.newpipe.App; import org.schabi.newpipe.BuildConfig; +import org.schabi.newpipe.LocalPlayerActivity; import org.schabi.newpipe.R; import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.ErrorUtil; @@ -116,8 +121,12 @@ public class MissionAdapter extends Adapter implements Handler.Callb private final CompositeDisposable compositeDisposable = new CompositeDisposable(); + private SharedPreferences mPrefs; + public MissionAdapter(Context context, @NonNull DownloadManager downloadManager, View emptyMessage, View root) { mContext = context; + mPrefs = PreferenceManager.getDefaultSharedPreferences(App.getApp()); + mDownloadManager = downloadManager; mInflater = LayoutInflater.from(mContext); @@ -332,7 +341,25 @@ private void updateProgress(ViewHolderItem h) { } } - private void viewWithFileProvider(Mission mission) { + private void open(Mission mission) { + if (checkInvalidFile(mission)) return; + + String mimeType = resolveMimeType(mission); + + if (BuildConfig.DEBUG) + Log.v(TAG, "Mime: " + mimeType + " package: " + BuildConfig.APPLICATION_ID + ".provider"); + + Uri uri = resolveShareableUri(mission); + + Intent intent = new Intent(mContext, LocalPlayerActivity.class); + intent.setDataAndType(uri, mimeType); + intent.putExtra("segments", mission.segmentsJson); + intent.setFlags(FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_REORDER_TO_FRONT); + + mContext.startActivity(intent); + } + + private void openExternally(Mission mission) { if (checkInvalidFile(mission)) return; String mimeType = resolveMimeType(mission); @@ -362,7 +389,7 @@ private void shareFile(Mission mission) { if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.O_MR1) { intent.putExtra(Intent.EXTRA_TITLE, mContext.getString(R.string.share_dialog_title)); } - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.addFlags(FLAG_ACTIVITY_NEW_TASK); intent.addFlags(FLAG_GRANT_READ_URI_PERMISSION); mContext.startActivity(intent); @@ -662,6 +689,9 @@ private boolean handlePopupItem(@NonNull ViewHolderItem h, @NonNull MenuItem opt applyChanges(); checkMasterButtonsVisibility(); return true; + case R.id.open_externally: + openExternally(h.item.mission); + return true; case R.id.md5: case R.id.sha1: final NotificationManager notificationManager @@ -878,8 +908,14 @@ class ViewHolderItem extends RecyclerView.ViewHolder { itemView.setHapticFeedbackEnabled(true); itemView.setOnClickListener(v -> { - if (item.mission instanceof FinishedMission) - viewWithFileProvider(item.mission); + if (item.mission instanceof FinishedMission) { + if (mPrefs.getBoolean(mContext + .getString(R.string.enable_local_player_key), false)) { + open(item.mission); + } else { + openExternally(item.mission); + } + } }); itemView.setOnLongClickListener(v -> { diff --git a/app/src/main/res/color/mtrl_search_filter_chip_background_color.xml b/app/src/main/res/color/mtrl_search_filter_chip_background_color.xml new file mode 100644 index 0000000000..1bea84d4e6 --- /dev/null +++ b/app/src/main/res/color/mtrl_search_filter_chip_background_color.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable-night/ic_sponsor_block_disable.xml b/app/src/main/res/drawable-night/ic_sponsor_block_disable.xml new file mode 100644 index 0000000000..be75c23261 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_sponsor_block_disable.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/app/src/main/res/drawable-night/ic_sponsor_block_enable.xml b/app/src/main/res/drawable-night/ic_sponsor_block_enable.xml new file mode 100644 index 0000000000..0e7a797653 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_sponsor_block_enable.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable-night/ic_sponsor_block_exclude.xml b/app/src/main/res/drawable-night/ic_sponsor_block_exclude.xml new file mode 100644 index 0000000000..ca5a259079 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_sponsor_block_exclude.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_brave_settings.xml b/app/src/main/res/drawable/ic_brave_settings.xml new file mode 100644 index 0000000000..7bface8152 --- /dev/null +++ b/app/src/main/res/drawable/ic_brave_settings.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_placeholder_bitchute.xml b/app/src/main/res/drawable/ic_placeholder_bitchute.xml new file mode 100644 index 0000000000..8adb7783ea --- /dev/null +++ b/app/src/main/res/drawable/ic_placeholder_bitchute.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/ic_placeholder_rumble.xml b/app/src/main/res/drawable/ic_placeholder_rumble.xml new file mode 100644 index 0000000000..e536581873 --- /dev/null +++ b/app/src/main/res/drawable/ic_placeholder_rumble.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/layout/activity_local_player.xml b/app/src/main/res/layout/activity_local_player.xml new file mode 100644 index 0000000000..dc5d114518 --- /dev/null +++ b/app/src/main/res/layout/activity_local_player.xml @@ -0,0 +1,16 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/chip_search_filter.xml b/app/src/main/res/layout/chip_search_filter.xml new file mode 100644 index 0000000000..58fd1b5abe --- /dev/null +++ b/app/src/main/res/layout/chip_search_filter.xml @@ -0,0 +1,9 @@ + + + + diff --git a/app/src/main/res/layout/download_dialog.xml b/app/src/main/res/layout/download_dialog.xml index 67aa1577c0..7b4cca5e03 100644 --- a/app/src/main/res/layout/download_dialog.xml +++ b/app/src/main/res/layout/download_dialog.xml @@ -18,16 +18,28 @@ android:layout_marginBottom="6dp" android:text="@string/msg_name" /> + + + android:maxLines="1" + android:visibility="gone" + tools:visibility="visible" /> + + + + + + + + + + + +