diff --git a/.github/ISSUE_TEMPLATE/application-bug.yml b/.github/ISSUE_TEMPLATE/application-bug.yml
index 931db3bd..f3590067 100644
--- a/.github/ISSUE_TEMPLATE/application-bug.yml
+++ b/.github/ISSUE_TEMPLATE/application-bug.yml
@@ -80,13 +80,13 @@ body:
label: Acknowledgements
description: Your issue will be closed if you haven't done these steps.
options:
+ - label: I am sure my issue is related to the app and **NOT some extension**.
+ required: true
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
required: true
- label: I have written a short but informative title.
required: true
- label: I have updated the app to pre-release version **[Latest](https://github.com/recloudstream/cloudstream/releases)**.
required: true
- - label: If related to a provider, I have checked the site and it works, but not the app.
- required: true
- label: I will fill out all of the requested information in this form.
required: true
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
index cd3c2574..b56cdf8e 100644
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -1,8 +1,8 @@
blank_issues_enabled: false
contact_links:
- - name: Report provider bug
+ - name: Request a new provider or report bug with an existing provider
url: https://github.com/recloudstream
- about: Please do not report any provider bugs here. This repository does not contain any providers. Please find the appropriate repository and report your issue there or join the discord.
+ about: EXTREMELY IMPORTANT - Please do not report any provider bugs here or request new providers. This repository does not contain any providers. Please find the appropriate repository and report your issue there or join the discord.
- name: Discord
url: https://discord.gg/5Hus6fM
about: Join our discord for faster support on smaller issues.
diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml
index 9c35ba56..e18daebb 100644
--- a/.github/ISSUE_TEMPLATE/feature-request.yml
+++ b/.github/ISSUE_TEMPLATE/feature-request.yml
@@ -27,9 +27,7 @@ body:
label: Acknowledgements
description: Your issue will be closed if you haven't done these steps.
options:
+ - label: My suggestion is **NOT** about adding a new provider
+ required: true
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
- required: true
- - label: I have written a short but informative title.
- required: true
- - label: I will fill out all of the requested information in this form.
- required: true
+ required: true
\ No newline at end of file
diff --git a/.github/downloads.jpg b/.github/downloads.jpg
deleted file mode 100644
index 0b671edc..00000000
Binary files a/.github/downloads.jpg and /dev/null differ
diff --git a/.github/home.jpg b/.github/home.jpg
deleted file mode 100644
index 2ccfaff4..00000000
Binary files a/.github/home.jpg and /dev/null differ
diff --git a/.github/locales.py b/.github/locales.py
new file mode 100644
index 00000000..a74d7258
--- /dev/null
+++ b/.github/locales.py
@@ -0,0 +1,69 @@
+import re
+import glob
+import requests
+import os
+import lxml.etree as ET # builtin library doesn't preserve comments
+
+
+SETTINGS_PATH = "app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt"
+START_MARKER = "/* begin language list */"
+END_MARKER = "/* end language list */"
+XML_NAME = "app/src/main/res/values-"
+ISO_MAP_URL = "https://raw.githubusercontent.com/haliaeetus/iso-639/master/data/iso_639-1.min.json"
+INDENT = " "*4
+
+iso_map = requests.get(ISO_MAP_URL, timeout=300).json()
+
+# Load settings file
+src = open(SETTINGS_PATH, "r", encoding='utf-8').read()
+before_src, rest = src.split(START_MARKER)
+rest, after_src = rest.split(END_MARKER)
+
+# Load already added langs
+languages = {}
+for lang in re.finditer(r'Triple\("(.*)", "(.*)", "(.*)"\)', rest):
+ flag, name, iso = lang.groups()
+ languages[iso] = (flag, name)
+
+# Add not yet added langs
+for folder in glob.glob(f"{XML_NAME}*"):
+ iso = folder[len(XML_NAME):]
+ if iso not in languages.keys():
+ entry = iso_map.get(iso.lower(),{'nativeName':iso})
+ languages[iso] = ("", entry['nativeName'].split(',')[0])
+
+# Create triples
+triples = []
+for iso in sorted(languages.keys()):
+ flag, name = languages[iso]
+ triples.append(f'{INDENT}Triple("{flag}", "{name}", "{iso}"),')
+
+# Update settings file
+open(SETTINGS_PATH, "w+",encoding='utf-8').write(
+ before_src +
+ START_MARKER +
+ "\n" +
+ "\n".join(triples) +
+ "\n" +
+ END_MARKER +
+ after_src
+)
+
+# Go through each values.xml file and fix escaped \@string
+for file in glob.glob(f"{XML_NAME}*/strings.xml"):
+ try:
+ tree = ET.parse(file)
+ for child in tree.getroot():
+ if not child.text:
+ continue
+ if child.text.startswith("\\@string/"):
+ print(f"[{file}] fixing {child.attrib['name']}")
+ child.text = child.text.replace("\\@string/", "@string/")
+ with open(file, 'wb') as fp:
+ fp.write(b'\n')
+ tree.write(fp, encoding="utf-8", method="xml", pretty_print=True, xml_declaration=False)
+ # Remove trailing new line to be consistent with weblate
+ fp.seek(-1, os.SEEK_END)
+ fp.truncate()
+ except ET.ParseError as ex:
+ print(f"[{file}] {ex}")
diff --git a/.github/player.jpg b/.github/player.jpg
deleted file mode 100644
index 0580fb03..00000000
Binary files a/.github/player.jpg and /dev/null differ
diff --git a/.github/results.jpg b/.github/results.jpg
deleted file mode 100644
index 5e63169f..00000000
Binary files a/.github/results.jpg and /dev/null differ
diff --git a/.github/search.jpg b/.github/search.jpg
deleted file mode 100644
index 998b7753..00000000
Binary files a/.github/search.jpg and /dev/null differ
diff --git a/.github/workflows/build_to_archive.yml b/.github/workflows/build_to_archive.yml
new file mode 100644
index 00000000..e84bb08b
--- /dev/null
+++ b/.github/workflows/build_to_archive.yml
@@ -0,0 +1,78 @@
+name: Archive build
+
+on:
+ push:
+ branches: [ master ]
+ paths-ignore:
+ - '*.md'
+ - '*.json'
+ - '**/wcokey.txt'
+ workflow_dispatch:
+
+concurrency:
+ group: "Archive-build"
+ cancel-in-progress: true
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Generate access token
+ id: generate_token
+ uses: tibdex/github-app-token@v2
+ with:
+ app_id: ${{ secrets.GH_APP_ID }}
+ private_key: ${{ secrets.GH_APP_KEY }}
+ repository: "recloudstream/secrets"
+ - name: Generate access token (archive)
+ id: generate_archive_token
+ uses: tibdex/github-app-token@v2
+ with:
+ app_id: ${{ secrets.GH_APP_ID }}
+ private_key: ${{ secrets.GH_APP_KEY }}
+ repository: "recloudstream/cloudstream-archive"
+ - uses: actions/checkout@v4
+ - name: Set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ java-version: '17'
+ distribution: 'adopt'
+ - name: Grant execute permission for gradlew
+ run: chmod +x gradlew
+ - name: Fetch keystore
+ id: fetch_keystore
+ run: |
+ TMP_KEYSTORE_FILE_PATH="${RUNNER_TEMP}"/keystore
+ mkdir -p "${TMP_KEYSTORE_FILE_PATH}"
+ curl -H "Authorization: token ${{ steps.generate_token.outputs.token }}" -o "${TMP_KEYSTORE_FILE_PATH}/prerelease_keystore.keystore" "https://raw.githubusercontent.com/recloudstream/secrets/master/keystore.jks"
+ curl -H "Authorization: token ${{ steps.generate_token.outputs.token }}" -o "keystore_password.txt" "https://raw.githubusercontent.com/recloudstream/secrets/master/keystore_password.txt"
+ KEY_PWD="$(cat keystore_password.txt)"
+ echo "::add-mask::${KEY_PWD}"
+ echo "key_pwd=$KEY_PWD" >> $GITHUB_OUTPUT
+ - name: Run Gradle
+ run: |
+ ./gradlew assemblePrerelease
+ env:
+ SIGNING_KEY_ALIAS: "key0"
+ SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
+ SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
+ SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }}
+ SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
+ - uses: actions/checkout@v4
+ with:
+ repository: "recloudstream/cloudstream-archive"
+ token: ${{ steps.generate_archive_token.outputs.token }}
+ path: "archive"
+
+ - name: Move build
+ run: |
+ cp app/build/outputs/apk/prerelease/release/*.apk "archive/$(git rev-parse --short HEAD).apk"
+
+ - name: Push archive
+ run: |
+ cd $GITHUB_WORKSPACE/archive
+ git config --local user.email "actions@github.com"
+ git config --local user.name "GitHub Actions"
+ git add .
+ git commit --amend -m "Build $GITHUB_SHA" || exit 0 # do not error if nothing to commit
+ git push --force
\ No newline at end of file
diff --git a/.github/workflows/generate_dokka.yml b/.github/workflows/generate_dokka.yml
index 032ea8d0..96e61644 100644
--- a/.github/workflows/generate_dokka.yml
+++ b/.github/workflows/generate_dokka.yml
@@ -20,7 +20,7 @@ jobs:
steps:
- name: Generate access token
id: generate_token
- uses: tibdex/github-app-token@v1
+ uses: tibdex/github-app-token@v2
with:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_KEY }}
@@ -39,17 +39,17 @@ jobs:
- name: Clean old builds
run: |
- shopt -s extglob
cd $GITHUB_WORKSPACE/dokka/
- rm -rf !(.git)
+ rm -rf "./-cloudstream"
- - name: Setup JDK 11
- uses: actions/setup-java@v1
+ - name: Setup JDK 17
+ uses: actions/setup-java@v4
with:
- java-version: 11
+ java-version: 17
+ distribution: 'adopt'
- name: Setup Android SDK
- uses: android-actions/setup-android@v2
+ uses: android-actions/setup-android@v3
- name: Generate Dokka
run: |
diff --git a/.github/workflows/issue-action.yml b/.github/workflows/issue_action.yml
similarity index 67%
rename from .github/workflows/issue-action.yml
rename to .github/workflows/issue_action.yml
index bfcb10d0..88ab3656 100644
--- a/.github/workflows/issue-action.yml
+++ b/.github/workflows/issue_action.yml
@@ -1,63 +1,88 @@
-name: Issue automatic actions
-
-on:
- issues:
- types: [opened, edited]
-
-jobs:
- issue-moderator:
- runs-on: ubuntu-latest
- steps:
- - name: Generate access token
- id: generate_token
- uses: tibdex/github-app-token@v1
- with:
- app_id: ${{ secrets.GH_APP_ID }}
- private_key: ${{ secrets.GH_APP_KEY }}
- - name: Similarity analysis
- uses: actions-cool/issues-similarity-analysis@v1
- with:
- token: ${{ steps.generate_token.outputs.token }}
- filter-threshold: 0.5
- title-excludes: ''
- comment-title: |
- ### Your issue looks similar to these issues:
- Please close if duplicate.
- comment-body: '${index}. ${similarity} #${number}'
- - uses: actions/checkout@v2
- - name: Automatically close issues that dont follow the issue template
- uses: lucasbento/auto-close-issues@v1.0.2
- with:
- github-token: ${{ steps.generate_token.outputs.token }}
- issue-close-message: |
- @${issue.user.login}: hello! :wave:
- This issue is being automatically closed because it does not follow the issue template."
- closed-issues-label: "invalid"
- - name: Check if issue mentions a provider
- id: provider_check
- env:
- GH_TEXT: "${{ github.event.issue.title }} ${{ github.event.issue.body }}"
- run: |
- wget --output-document check_issue.py "https://raw.githubusercontent.com/recloudstream/.github/master/.github/check_issue.py"
- pip3 install httpx
- RES="$(python3 ./check_issue.py)"
- echo "::set-output name=name::${RES}"
- - name: Comment if issue mentions a provider
- if: steps.provider_check.outputs.name != 'none'
- uses: actions-cool/issues-helper@v3
- with:
- actions: 'create-comment'
- token: ${{ steps.generate_token.outputs.token }}
- body: |
- Hello ${{ github.event.issue.user.login }}.
- Please do not report any provider bugs here. This repository does not contain any providers. Please find the appropriate repository and report your issue there or join the [discord](https://discord.gg/5Hus6fM).
-
- Found provider name: `${{ steps.provider_check.outputs.name }}`
- - name: Add eyes reaction to all issues
- uses: actions-cool/emoji-helper@v1.0.0
- with:
- type: 'issue'
- token: ${{ steps.generate_token.outputs.token }}
- emoji: 'eyes'
-
-
+name: Issue automatic actions
+
+on:
+ issues:
+ types: [opened]
+
+jobs:
+ issue-moderator:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Generate access token
+ id: generate_token
+ uses: tibdex/github-app-token@v2
+ with:
+ app_id: ${{ secrets.GH_APP_ID }}
+ private_key: ${{ secrets.GH_APP_KEY }}
+ - name: Similarity analysis
+ id: similarity
+ uses: actions-cool/issues-similarity-analysis@v1
+ with:
+ token: ${{ steps.generate_token.outputs.token }}
+ filter-threshold: 0.60
+ title-excludes: ''
+ comment-title: |
+ ### Your issue looks similar to these issues:
+ Please close if duplicate.
+ comment-body: '${index}. ${similarity} #${number}'
+ - name: Label if possible duplicate
+ if: steps.similarity.outputs.similar-issues-found =='true'
+ uses: actions/github-script@v7
+ with:
+ github-token: ${{ steps.generate_token.outputs.token }}
+ script: |
+ github.rest.issues.addLabels({
+ issue_number: context.issue.number,
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ labels: ["possible duplicate"]
+ })
+ - uses: actions/checkout@v4
+ - name: Automatically close issues that dont follow the issue template
+ uses: lucasbento/auto-close-issues@v1.0.2
+ with:
+ github-token: ${{ steps.generate_token.outputs.token }}
+ issue-close-message: |
+ @${issue.user.login}: hello! :wave:
+ This issue is being automatically closed because it does not follow the issue template."
+ closed-issues-label: "invalid"
+ - name: Check if issue mentions a provider
+ id: provider_check
+ env:
+ GH_TEXT: "${{ github.event.issue.title }} ${{ github.event.issue.body }}"
+ run: |
+ wget --output-document check_issue.py "https://raw.githubusercontent.com/recloudstream/.github/master/.github/check_issue.py"
+ pip3 install httpx
+ RES="$(python3 ./check_issue.py)"
+ echo "name=${RES}" >> $GITHUB_OUTPUT
+ - name: Comment if issue mentions a provider
+ if: steps.provider_check.outputs.name != 'none'
+ uses: actions-cool/issues-helper@v3
+ with:
+ actions: 'create-comment'
+ token: ${{ steps.generate_token.outputs.token }}
+ body: |
+ Hello ${{ github.event.issue.user.login }}.
+ Please do not report any provider bugs here. This repository does not contain any providers. Please find the appropriate repository and report your issue there or join the [discord](https://discord.gg/5Hus6fM).
+
+ Found provider name: `${{ steps.provider_check.outputs.name }}`
+ - name: Label if mentions provider
+ if: steps.provider_check.outputs.name != 'none'
+ uses: actions/github-script@v7
+ with:
+ github-token: ${{ steps.generate_token.outputs.token }}
+ script: |
+ github.rest.issues.addLabels({
+ issue_number: context.issue.number,
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ labels: ["possible provider issue"]
+ })
+ - name: Add eyes reaction to all issues
+ uses: actions-cool/emoji-helper@v1.0.0
+ with:
+ type: 'issue'
+ token: ${{ steps.generate_token.outputs.token }}
+ emoji: 'eyes'
+
+
diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml
index 71301e25..f35cd58c 100644
--- a/.github/workflows/prerelease.yml
+++ b/.github/workflows/prerelease.yml
@@ -18,16 +18,16 @@ jobs:
steps:
- name: Generate access token
id: generate_token
- uses: tibdex/github-app-token@v1
+ uses: tibdex/github-app-token@v2
with:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_KEY }}
repository: "recloudstream/secrets"
- - uses: actions/checkout@v2
- - name: Set up JDK 11
- uses: actions/setup-java@v2
+ - uses: actions/checkout@v4
+ - name: Set up JDK 17
+ uses: actions/setup-java@v4
with:
- java-version: '11'
+ java-version: '17'
distribution: 'adopt'
- name: Grant execute permission for gradlew
run: chmod +x gradlew
@@ -40,24 +40,25 @@ jobs:
curl -H "Authorization: token ${{ steps.generate_token.outputs.token }}" -o "keystore_password.txt" "https://raw.githubusercontent.com/recloudstream/secrets/master/keystore_password.txt"
KEY_PWD="$(cat keystore_password.txt)"
echo "::add-mask::${KEY_PWD}"
- echo "::set-output name=key_pwd::$KEY_PWD"
+ echo "key_pwd=$KEY_PWD" >> $GITHUB_OUTPUT
- name: Run Gradle
run: |
- ./gradlew assemblePrerelease
- ./gradlew androidSourcesJar
- ./gradlew makeJar
+ ./gradlew assemblePrerelease build androidSourcesJar
+ ./gradlew makeJar # for classes.jar, has to be done after assemblePrerelease
env:
SIGNING_KEY_ALIAS: "key0"
SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
+ SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }}
+ SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
- name: Create pre-release
uses: "marvinpinto/action-automatic-releases@latest"
with:
repo_token: "${{ secrets.GITHUB_TOKEN }}"
automatic_release_tag: "pre-release"
- prerelease: false
+ prerelease: true
title: "Pre-release Build"
files: |
- app/build/outputs/apk/prerelease/*.apk
+ app/build/outputs/apk/prerelease/release/*.apk
app/build/libs/app-sources.jar
app/build/classes.jar
diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml
index 1a4db134..7f6dd412 100644
--- a/.github/workflows/pull_request.yml
+++ b/.github/workflows/pull_request.yml
@@ -6,18 +6,18 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v2
- - name: Set up JDK 11
- uses: actions/setup-java@v2
+ - uses: actions/checkout@v4
+ - name: Set up JDK 17
+ uses: actions/setup-java@v4
with:
- java-version: '11'
+ java-version: '17'
distribution: 'adopt'
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Run Gradle
- run: ./gradlew assembleDebug
+ run: ./gradlew assemblePrereleaseDebug
- name: Upload Artifact
- uses: actions/upload-artifact@v2
+ uses: actions/upload-artifact@v4
with:
name: pull-request-build
- path: "app/build/outputs/apk/debug/*.apk"
+ path: "app/build/outputs/apk/prerelease/debug/*.apk"
diff --git a/.github/workflows/update_locales.yml b/.github/workflows/update_locales.yml
new file mode 100644
index 00000000..ce140e55
--- /dev/null
+++ b/.github/workflows/update_locales.yml
@@ -0,0 +1,42 @@
+name: Fix locale issues
+
+on:
+ workflow_dispatch:
+ push:
+ paths:
+ - '**.xml'
+ branches:
+ - master
+
+concurrency:
+ group: "locale"
+ cancel-in-progress: true
+
+jobs:
+ create:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Generate access token
+ id: generate_token
+ uses: tibdex/github-app-token@v2
+ with:
+ app_id: ${{ secrets.GH_APP_ID }}
+ private_key: ${{ secrets.GH_APP_KEY }}
+ repository: "recloudstream/cloudstream"
+ - uses: actions/checkout@v4
+ with:
+ token: ${{ steps.generate_token.outputs.token }}
+ - name: Install dependencies
+ run: |
+ pip3 install lxml
+ - name: Edit files
+ run: |
+ python3 .github/locales.py
+ - name: Commit to the repo
+ run: |
+ git config --local user.email "111277985+recloudstream[bot]@users.noreply.github.com"
+ git config --local user.name "recloudstream[bot]"
+ git add .
+ # "echo" returns true so the build succeeds, even if no changed files
+ git commit -m 'chore(locales): fix locale issues' || echo
+ git push
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
index 5421743a..b589d56e 100644
--- a/.idea/compiler.xml
+++ b/.idea/compiler.xml
@@ -1,6 +1,6 @@
-
+
-
+
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index 10c26704..d7c08c9c 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -4,17 +4,16 @@
diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml
index 652d9f3f..333d4937 100644
--- a/.idea/jarRepositories.xml
+++ b/.idea/jarRepositories.xml
@@ -31,5 +31,10 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/README.md b/README.md
index 0035daf7..8949304e 100644
--- a/README.md
+++ b/README.md
@@ -1,44 +1,19 @@
# CloudStream
**⚠️ Warning: By default this app doesn't provide any video sources, you have to install extensions in order to add functionality to the app.**
-You can find the list of community-maintained extension repositories [here
-](https://recloudstream.github.io/repos/)
-[](https://discord.gg/5Hus6fM)
+[](https://discord.gg/5Hus6fM)
-***Features:***
+### Features:
+ **AdFree**, No ads whatsoever
+ No tracking/analytics
+ Bookmarks
-+ Download and stream movies, tv-shows and anime
++ Phone and TV support
+ Chromecast
++ Extension system for personal customization
-***Screenshots:***
-
-


-
-
-***The list of supported languages:***
-* 🇱🇧 Arabic
-* 🇨🇿 Czech
-* 🇳🇱 Dutch
-* 🇬🇧 English
-* 🇫🇷 French
-* 🇩🇪 German
-* 🇬🇷 Greek
-* 🇮🇳 Hindi
-* 🇮🇩 Indonesian
-* 🇮🇹 Italian
-* 🇲🇰 Macedonian
-* 🇮🇳 Malayalam
-* 🇳🇴 Norsk
-* 🇵🇱 Polish
-* 🇧🇷 Portuguese (Brazil)
-* 🇷🇴 Romanian
-* 🇪🇸 Spanish
-* 🇸🇪 Swedish
-* 🇵🇭 Tagalog
-* 🇹🇷 Turkish
-* 🇻🇳 Vietnamese
-
+### Supported languages:
+
+
+
diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt
new file mode 100644
index 00000000..7f7fd14c
--- /dev/null
+++ b/app/CMakeLists.txt
@@ -0,0 +1,6 @@
+# Set this to the minimum version your project supports.
+cmake_minimum_required(VERSION 3.18)
+project(CrashHandler)
+find_library(log-lib log)
+add_library(native-lib SHARED src/main/cpp/native-lib.cpp)
+target_link_libraries(native-lib ${log-lib})
diff --git a/app/build.gradle b/app/build.gradle
deleted file mode 100644
index 1aa62378..00000000
--- a/app/build.gradle
+++ /dev/null
@@ -1,215 +0,0 @@
-plugins {
- id 'com.android.application'
- id 'kotlin-android'
- id 'kotlin-kapt'
- id 'kotlin-android-extensions'
- id 'org.jetbrains.dokka'
-}
-
-def tmpFilePath = System.getProperty("user.home") + "/work/_temp/keystore/"
-def allFilesFromDir = new File(tmpFilePath).listFiles()
-def prereleaseStoreFile = null
-if (allFilesFromDir != null) {
- prereleaseStoreFile = allFilesFromDir.first()
-}
-
-android {
- testOptions {
- unitTests.returnDefaultValues = true
- }
- signingConfigs {
- prerelease {
- if (prereleaseStoreFile != null) {
- storeFile = file(prereleaseStoreFile)
- storePassword System.getenv("SIGNING_STORE_PASSWORD")
- keyAlias System.getenv("SIGNING_KEY_ALIAS")
- keyPassword System.getenv("SIGNING_KEY_PASSWORD")
- }
- }
- }
- compileSdkVersion 31
- buildToolsVersion "30.0.3"
-
- defaultConfig {
- applicationId "com.lagradost.cloudstream3"
- minSdkVersion 21
- targetSdkVersion 30
-
- versionCode 50
- versionName "3.1.3"
-
- resValue "string", "app_version",
- "${defaultConfig.versionName}${versionNameSuffix ?: ""}"
-
- resValue "string", "commit_hash",
- ("git rev-parse --short HEAD".execute().text.trim() ?: "")
-
- resValue "bool", "is_prerelease", "false"
-
- buildConfigField("String", "BUILDDATE", "new java.text.SimpleDateFormat(\"yyyy-MM-dd HH:mm\").format(new java.util.Date(" + System.currentTimeMillis() + "L));")
-
- testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
-
- kapt {
- includeCompileClasspath = true
- }
- }
-
- buildTypes {
- // release {
- // debuggable false
- // minifyEnabled false
- // shrinkResources false
- // proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
- // resValue "bool", "is_prerelease", "false"
- // }
- prerelease {
- applicationIdSuffix ".prerelease"
- buildConfigField("boolean", "BETA", "true")
- signingConfig signingConfigs.prerelease
- versionNameSuffix '-PRE'
- debuggable false
- minifyEnabled false
- shrinkResources false
- proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
- resValue "bool", "is_prerelease", "true"
- }
- debug {
- debuggable true
- applicationIdSuffix ".debug"
- proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
- resValue "bool", "is_prerelease", "true"
- }
- }
- compileOptions {
- coreLibraryDesugaringEnabled true
-
- sourceCompatibility JavaVersion.VERSION_1_8
- targetCompatibility JavaVersion.VERSION_1_8
- }
- kotlinOptions {
- jvmTarget = '1.8'
- freeCompilerArgs = ['-Xjvm-default=compatibility']
- }
- lintOptions {
- checkReleaseBuilds false
- abortOnError false
- }
-}
-
-repositories {
- maven { url 'https://jitpack.io' }
-}
-
-dependencies {
- implementation 'com.google.android.mediahome:video:1.0.0'
- implementation 'androidx.test.ext:junit-ktx:1.1.3'
- testImplementation 'org.json:json:20180813'
-
- implementation 'androidx.core:core-ktx:1.8.0'
- implementation 'androidx.appcompat:appcompat:1.4.2'
-
- // dont change this to 1.6.0 it looks ugly af
- implementation 'com.google.android.material:material:1.5.0'
- implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
- implementation 'androidx.navigation:navigation-fragment-ktx:2.5.1'
- implementation 'androidx.navigation:navigation-ui-ktx:2.5.1'
- implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.1'
- implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1'
- testImplementation 'junit:junit:4.13.2'
- androidTestImplementation 'androidx.test.ext:junit:1.1.3'
- androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
-
- //implementation "io.karn:khttp-android:0.1.2" //okhttp instead
-// implementation 'org.jsoup:jsoup:1.13.1'
- implementation "com.fasterxml.jackson.module:jackson-module-kotlin:2.13.1"
-
- implementation "androidx.preference:preference-ktx:1.2.0"
-
- implementation 'com.github.bumptech.glide:glide:4.13.1'
- kapt 'com.github.bumptech.glide:compiler:4.13.1'
- implementation 'com.github.bumptech.glide:okhttp3-integration:4.13.0'
-
- implementation 'jp.wasabeef:glide-transformations:4.3.0'
-
- implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
-
- // implementation "androidx.leanback:leanback-paging:1.1.0-alpha09"
-
- // Exoplayer
- implementation 'com.google.android.exoplayer:exoplayer:2.16.1'
- implementation 'com.google.android.exoplayer:extension-cast:2.16.1'
- implementation "com.google.android.exoplayer:extension-mediasession:2.16.1"
- implementation 'com.google.android.exoplayer:extension-okhttp:2.16.1'
-
- //implementation "com.google.android.exoplayer:extension-leanback:2.14.0"
-
- // Bug reports
- implementation "ch.acra:acra-core:5.8.4"
- implementation "ch.acra:acra-toast:5.8.4"
-
- compileOnly "com.google.auto.service:auto-service-annotations:1.0"
- //either for java sources:
- annotationProcessor "com.google.auto.service:auto-service:1.0"
- //or for kotlin sources (requires kapt gradle plugin):
- kapt "com.google.auto.service:auto-service:1.0"
-
- // subtitle color picker
- implementation 'com.jaredrummler:colorpicker:1.1.0'
-
- //run JS
- implementation 'org.mozilla:rhino:1.7.14'
-
- // TorrentStream
- //implementation 'com.github.TorrentStream:TorrentStream-Android:2.7.0'
-
- // Downloading
- implementation "androidx.work:work-runtime:2.7.1"
- implementation "androidx.work:work-runtime-ktx:2.7.1"
-
- // Networking
-// implementation "com.squareup.okhttp3:okhttp:4.9.2"
-// implementation "com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.1"
- implementation 'com.github.Blatzar:NiceHttp:0.3.2'
-
- // Util to skip the URI file fuckery 🙏
- implementation "com.github.tachiyomiorg:unifile:17bec43"
-
- // API because cba maintaining it myself
- implementation "com.uwetrottmann.tmdb2:tmdb-java:2.6.0"
-
- implementation 'com.github.discord:OverlappingPanels:0.1.3'
- // debugImplementation because LeakCanary should only run in debug builds.
- // debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.7'
-
- // for shimmer when loading
- implementation 'com.facebook.shimmer:shimmer:0.5.0'
-
- implementation "androidx.tvprovider:tvprovider:1.0.0"
-
- // used for subtitle decoding https://github.com/albfernandez/juniversalchardet
- implementation 'com.github.albfernandez:juniversalchardet:2.4.0'
-
- // slow af yt
- //implementation 'com.github.HaarigerHarald:android-youtubeExtractor:master-SNAPSHOT'
-
- // newpipe yt
- implementation 'com.github.recloudstream:NewPipeExtractor:master-SNAPSHOT'
- coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
-
- // Library/extensions searching with Levenshtein distance
- implementation 'me.xdrop:fuzzywuzzy:1.4.0'
-}
-
-task androidSourcesJar(type: Jar) {
- getArchiveClassifier().set('sources')
- from android.sourceSets.main.java.srcDirs//full sources
-}
-
-task makeJar(type: Copy) {
- // after modifying here, you can export. Jar
- from('build/intermediates/compile_app_classes_jar/debug')
- into('build') // output location
- include('classes.jar') // the classes file of the imported rack package
- dependsOn build
-}
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 00000000..d0c86bab
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,304 @@
+import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
+import org.jetbrains.dokka.gradle.DokkaTask
+import org.jetbrains.kotlin.gradle.plugin.mpp.pm20.util.archivesName
+import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
+import java.io.ByteArrayOutputStream
+import java.net.URL
+
+plugins {
+ id("com.android.application")
+ id("com.google.devtools.ksp")
+ id("kotlin-android")
+ id("org.jetbrains.dokka")
+}
+
+val tmpFilePath = System.getProperty("user.home") + "/work/_temp/keystore/"
+val prereleaseStoreFile: File? = File(tmpFilePath).listFiles()?.first()
+
+fun String.execute() = ByteArrayOutputStream().use { baot ->
+ if (project.exec {
+ workingDir = projectDir
+ commandLine = this@execute.split(Regex("\\s"))
+ standardOutput = baot
+ }.exitValue == 0)
+ String(baot.toByteArray()).trim()
+ else null
+}
+
+android {
+ testOptions {
+ unitTests.isReturnDefaultValues = true
+ }
+
+ viewBinding {
+ enable = true
+ }
+
+ /* disable this for now
+ externalNativeBuild {
+ cmake {
+ path("CMakeLists.txt")
+ }
+ }*/
+
+ signingConfigs {
+ if (prereleaseStoreFile != null) {
+ create("prerelease") {
+ storeFile = file(prereleaseStoreFile)
+ storePassword = System.getenv("SIGNING_STORE_PASSWORD")
+ keyAlias = System.getenv("SIGNING_KEY_ALIAS")
+ keyPassword = System.getenv("SIGNING_KEY_PASSWORD")
+ }
+ }
+ }
+
+ compileSdk = 34
+ buildToolsVersion = "34.0.0"
+
+ defaultConfig {
+ applicationId = "com.lagradost.cloudstream3"
+ minSdk = 21
+ targetSdk = 33 /* Android 14 is Fu*ked
+ ^ https://developer.android.com/about/versions/14/behavior-changes-14#safer-dynamic-code-loading*/
+ versionCode = 64
+ versionName = "4.4.0"
+
+ resValue("string", "app_version", "${defaultConfig.versionName}${versionNameSuffix ?: ""}")
+ resValue("string", "commit_hash", "git rev-parse --short HEAD".execute() ?: "")
+ resValue("bool", "is_prerelease", "false")
+
+ // Reads local.properties
+ val localProperties = gradleLocalProperties(rootDir)
+
+ buildConfigField(
+ "long",
+ "BUILD_DATE",
+ "${System.currentTimeMillis()}"
+ )
+ buildConfigField(
+ "String",
+ "SIMKL_CLIENT_ID",
+ "\"" + (System.getenv("SIMKL_CLIENT_ID") ?: localProperties["simkl.id"]) + "\""
+ )
+ buildConfigField(
+ "String",
+ "SIMKL_CLIENT_SECRET",
+ "\"" + (System.getenv("SIMKL_CLIENT_SECRET") ?: localProperties["simkl.secret"]) + "\""
+ )
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+
+ ksp {
+ arg("room.schemaLocation", "$projectDir/schemas")
+ arg("exportSchema", "true")
+ }
+ }
+
+ buildTypes {
+ release {
+ isDebuggable = false
+ isMinifyEnabled = false
+ isShrinkResources = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ debug {
+ isDebuggable = true
+ applicationIdSuffix = ".debug"
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+
+ flavorDimensions.add("state")
+ productFlavors {
+ create("stable") {
+ dimension = "state"
+ resValue("bool", "is_prerelease", "false")
+ }
+ create("prerelease") {
+ dimension = "state"
+ resValue("bool", "is_prerelease", "true")
+ buildConfigField("boolean", "BETA", "true")
+ applicationIdSuffix = ".prerelease"
+ if (signingConfigs.names.contains("prerelease")) {
+ signingConfig = signingConfigs.getByName("prerelease")
+ } else {
+ logger.warn("No prerelease signing config!")
+ }
+ versionNameSuffix = "-PRE"
+ versionCode = (System.currentTimeMillis() / 60000).toInt()
+ }
+ }
+
+ compileOptions {
+ isCoreLibraryDesugaringEnabled = true
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+
+ lint {
+ abortOnError = false
+ checkReleaseBuilds = false
+ }
+
+ buildFeatures {
+ buildConfig = true
+ }
+
+ namespace = "com.lagradost.cloudstream3"
+}
+
+repositories {
+ maven("https://jitpack.io")
+}
+
+dependencies {
+ // Testing
+ testImplementation("junit:junit:4.13.2")
+ testImplementation("org.json:json:20240303")
+ androidTestImplementation("androidx.test:core")
+ implementation("androidx.test.ext:junit-ktx:1.2.1")
+ androidTestImplementation("androidx.test.ext:junit:1.2.1")
+ androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
+
+ // Android Core & Lifecycle
+ implementation("androidx.core:core-ktx:1.13.1")
+ implementation("androidx.appcompat:appcompat:1.7.0")
+ implementation("androidx.navigation:navigation-ui-ktx:2.7.7")
+ implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.8.3")
+ implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.3")
+ implementation("androidx.navigation:navigation-fragment-ktx:2.7.7")
+
+ // Design & UI
+ implementation("jp.wasabeef:glide-transformations:4.3.0")
+ implementation("androidx.preference:preference-ktx:1.2.1")
+ implementation("com.google.android.material:material:1.12.0")
+ implementation("androidx.constraintlayout:constraintlayout:2.1.4")
+ implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
+
+ // Glide Module
+ ksp("com.github.bumptech.glide:ksp:4.16.0")
+ implementation("com.github.bumptech.glide:glide:4.16.0")
+ implementation("com.github.bumptech.glide:okhttp3-integration:4.16.0")
+
+ // For KSP -> Official Annotation Processors are Not Yet Supported for KSP
+ ksp("dev.zacsweers.autoservice:auto-service-ksp:1.2.0")
+ implementation("com.google.guava:guava:33.2.1-android")
+ implementation("dev.zacsweers.autoservice:auto-service-ksp:1.2.0")
+
+ // Media 3 (ExoPlayer)
+ implementation("androidx.media3:media3-ui:1.1.1")
+ implementation("androidx.media3:media3-cast:1.1.1")
+ implementation("androidx.media3:media3-common:1.1.1")
+ implementation("androidx.media3:media3-session:1.1.1")
+ implementation("androidx.media3:media3-exoplayer:1.1.1")
+ implementation("com.google.android.mediahome:video:1.0.0")
+ implementation("androidx.media3:media3-exoplayer-hls:1.1.1")
+ implementation("androidx.media3:media3-exoplayer-dash:1.1.1")
+ implementation("androidx.media3:media3-datasource-okhttp:1.1.1")
+
+ // PlayBack
+ implementation("com.jaredrummler:colorpicker:1.1.0") // Subtitle Color Picker
+ implementation("com.github.recloudstream:media-ffmpeg:1.1.0") // Custom FF-MPEG Lib for Audio Codecs
+ implementation("com.github.teamnewpipe:NewPipeExtractor:176da72") /* For Trailers
+ ^ Update to Latest Commits if Trailers Misbehave, github.com/TeamNewPipe/NewPipeExtractor/commits/dev */
+ implementation("com.github.albfernandez:juniversalchardet:2.5.0") // Subtitle Decoding
+
+ // Crash Reports (AcraApplication.kt)
+ implementation("ch.acra:acra-core:5.11.3")
+ implementation("ch.acra:acra-toast:5.11.3")
+
+ // UI Stuff
+ implementation("com.facebook.shimmer:shimmer:0.5.0") // Shimmering Effect (Loading Skeleton)
+ implementation("androidx.palette:palette-ktx:1.0.0") // Palette For Images -> Colors
+ implementation("androidx.tvprovider:tvprovider:1.0.0")
+ implementation("com.github.discord:OverlappingPanels:0.1.5") // Gestures
+ implementation("androidx.biometric:biometric:1.2.0-alpha05") // Fingerprint Authentication
+ implementation("com.github.rubensousa:previewseekbar-media3:1.1.1.0") // SeekBar Preview
+ implementation("io.github.g0dkar:qrcode-kotlin:4.2.0") // QR code for PIN Auth on TV
+
+ // Extensions & Other Libs
+ implementation("org.mozilla:rhino:1.7.15") // run JavaScript
+ implementation("me.xdrop:fuzzywuzzy:1.4.0") // Library/Ext Searching with Levenshtein Distance
+ implementation("com.github.LagradOst:SafeFile:0.0.6") // To Prevent the URI File Fu*kery
+ implementation("org.conscrypt:conscrypt-android:2.5.2") // To Fix SSL Fu*kery on Android 9
+ implementation("com.uwetrottmann.tmdb2:tmdb-java:2.11.0") // TMDB API v3 Wrapper Made with RetroFit
+ coreLibraryDesugaring("com.android.tools:desugar_jdk_libs_nio:2.0.4") //nio flavor needed for NewPipeExtractor
+ implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.13.1") /* JSON Parser
+ ^ Don't Bump Jackson above 2.13.1 , Crashes on Android TV's and FireSticks that have Min API
+ Level 25 or Less. */
+
+ // Downloading & Networking
+ implementation("androidx.work:work-runtime:2.9.0")
+ implementation("androidx.work:work-runtime-ktx:2.9.0")
+ implementation("com.github.Blatzar:NiceHttp:0.4.11") // HTTP Lib
+
+ implementation(project(":library") {
+ // There does not seem to be a good way of getting the android flavor.
+ val isDebug = gradle.startParameter.taskRequests.any { task ->
+ task.args.any { arg ->
+ arg.contains("debug", true)
+ }
+ }
+
+ this.extra.set("isDebug", isDebug)
+ })
+}
+
+tasks.register("androidSourcesJar") {
+ archiveClassifier.set("sources")
+ from(android.sourceSets.getByName("main").java.srcDirs) // Full Sources
+}
+
+tasks.register("copyJar") {
+ from(
+ "build/intermediates/compile_app_classes_jar/prereleaseDebug",
+ "../library/build/libs"
+ )
+ into("build/app-classes")
+ include("classes.jar", "library-jvm*.jar")
+ // Remove the version
+ rename("library-jvm.*.jar", "library-jvm.jar")
+}
+
+// Merge the app classes and the library classes into classes.jar
+tasks.register("makeJar") {
+ // Duplicates cause hard to catch errors, better to fail at compile time.
+ duplicatesStrategy = DuplicatesStrategy.FAIL
+ dependsOn(tasks.getByName("copyJar"))
+ from(
+ zipTree("build/app-classes/classes.jar"),
+ zipTree("build/app-classes/library-jvm.jar")
+ )
+ destinationDirectory.set(layout.buildDirectory)
+ archivesName = "classes"
+}
+
+tasks.withType {
+ kotlinOptions {
+ jvmTarget = "1.8"
+ freeCompilerArgs = listOf("-Xjvm-default=all-compatibility")
+ }
+}
+
+tasks.withType().configureEach {
+ moduleName.set("Cloudstream")
+ dokkaSourceSets {
+ named("main") {
+ sourceLink {
+ // Unix based directory relative path to the root of the project (where you execute gradle respectively).
+ localDirectory.set(file("src/main/java"))
+
+ // URL showing where the source code can be accessed through the web browser
+ remoteUrl.set(URL("https://github.com/recloudstream/cloudstream/tree/master/app/src/main/java"))
+
+ // Suffix which is used to append the line number to the URL. Use #L for GitHub
+ remoteLineSuffix.set("#L")
+ }
+ }
+ }
+}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index 481bb434..ff59496d 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -1,6 +1,6 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
-# proguardFiles setting in build.gradle.
+# proguardFiles setting in build.gradle.kts.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
diff --git a/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt
index 201ddea3..c7f02baf 100644
--- a/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt
+++ b/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt
@@ -1,155 +1,57 @@
package com.lagradost.cloudstream3
+import android.app.Activity
+import android.os.Bundle
+import android.os.PersistableBundle
+import android.view.LayoutInflater
+import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
-import com.lagradost.cloudstream3.mvvm.logError
-import com.lagradost.cloudstream3.utils.Qualities
+import androidx.viewbinding.ViewBinding
+import com.lagradost.cloudstream3.databinding.FragmentHomeBinding
+import com.lagradost.cloudstream3.databinding.FragmentHomeTvBinding
+import com.lagradost.cloudstream3.databinding.FragmentLibraryBinding
+import com.lagradost.cloudstream3.databinding.FragmentLibraryTvBinding
+import com.lagradost.cloudstream3.databinding.FragmentPlayerBinding
+import com.lagradost.cloudstream3.databinding.FragmentPlayerTvBinding
+import com.lagradost.cloudstream3.databinding.FragmentResultBinding
+import com.lagradost.cloudstream3.databinding.FragmentResultTvBinding
+import com.lagradost.cloudstream3.databinding.FragmentSearchBinding
+import com.lagradost.cloudstream3.databinding.FragmentSearchTvBinding
+import com.lagradost.cloudstream3.databinding.HomeResultGridBinding
+import com.lagradost.cloudstream3.databinding.HomepageParentBinding
+import com.lagradost.cloudstream3.databinding.HomepageParentEmulatorBinding
+import com.lagradost.cloudstream3.databinding.HomepageParentTvBinding
+import com.lagradost.cloudstream3.databinding.PlayerCustomLayoutBinding
+import com.lagradost.cloudstream3.databinding.PlayerCustomLayoutTvBinding
+import com.lagradost.cloudstream3.databinding.RepositoryItemBinding
+import com.lagradost.cloudstream3.databinding.RepositoryItemTvBinding
+import com.lagradost.cloudstream3.databinding.SearchResultGridBinding
+import com.lagradost.cloudstream3.databinding.SearchResultGridExpandedBinding
+import com.lagradost.cloudstream3.databinding.TrailerCustomLayoutBinding
import com.lagradost.cloudstream3.utils.SubtitleHelper
+import com.lagradost.cloudstream3.utils.TestingUtils
import kotlinx.coroutines.runBlocking
import org.junit.Assert
import org.junit.Test
import org.junit.runner.RunWith
+
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
+class TestApplication : Activity() {
+ override fun onCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) {
+ super.onCreate(savedInstanceState, persistentState)
+ }
+}
+
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
- //@Test
- //fun useAppContext() {
- // // Context of the app under test.
- // val appContext = InstrumentationRegistry.getInstrumentation().targetContext
- // assertEquals("com.lagradost.cloudstream3", appContext.packageName)
- //}
-
- private fun getAllProviders(): List {
- return APIHolder.allProviders //.filter { !it.usesWebView }
- }
-
- private suspend fun loadLinks(api: MainAPI, url: String?): Boolean {
- Assert.assertNotNull("Api ${api.name} has invalid url on episode", url)
- if (url == null) return true
- var linksLoaded = 0
- try {
- val success = api.loadLinks(url, false, {}) { link ->
- Assert.assertTrue(
- "Api ${api.name} returns link with invalid Quality",
- Qualities.values().map { it.value }.contains(link.quality)
- )
- Assert.assertTrue(
- "Api ${api.name} returns link with invalid url ${link.url}",
- link.url.length > 4
- )
- linksLoaded++
- }
- if (success) {
- return linksLoaded > 0
- }
- Assert.assertTrue("Api ${api.name} has returns false on .loadLinks", success)
- } catch (e: Exception) {
- if (e.cause is NotImplementedError) {
- Assert.fail("Provider has not implemented .loadLinks")
- }
- logError(e)
- }
- return true
- }
-
- private suspend fun testSingleProviderApi(api: MainAPI): Boolean {
- val searchQueries = listOf("over", "iron", "guy")
- var correctResponses = 0
- var searchResult: List? = null
- for (query in searchQueries) {
- val response = try {
- api.search(query)
- } catch (e: Exception) {
- if (e.cause is NotImplementedError) {
- Assert.fail("Provider has not implemented .search")
- }
- logError(e)
- null
- }
- if (!response.isNullOrEmpty()) {
- correctResponses++
- if (searchResult == null) {
- searchResult = response
- }
- }
- }
-
- if (correctResponses == 0 || searchResult == null) {
- System.err.println("Api ${api.name} did not return any valid search responses")
- return false
- }
-
- try {
- var validResults = false
- for (result in searchResult) {
- Assert.assertEquals(
- "Invalid apiName on response on ${api.name}",
- result.apiName,
- api.name
- )
- val load = api.load(result.url) ?: continue
- Assert.assertEquals(
- "Invalid apiName on load on ${api.name}",
- load.apiName,
- result.apiName
- )
- Assert.assertTrue(
- "Api ${api.name} on load does not contain any of the supportedTypes",
- api.supportedTypes.contains(load.type)
- )
- when (load) {
- is AnimeLoadResponse -> {
- val gotNoEpisodes =
- load.episodes.keys.isEmpty() || load.episodes.keys.any { load.episodes[it].isNullOrEmpty() }
-
- if (gotNoEpisodes) {
- println("Api ${api.name} got no episodes on ${load.url}")
- continue
- }
-
- val url = (load.episodes[load.episodes.keys.first()])?.first()?.data
- validResults = loadLinks(api, url)
- if (!validResults) continue
- }
- is MovieLoadResponse -> {
- val gotNoEpisodes = load.dataUrl.isBlank()
- if (gotNoEpisodes) {
- println("Api ${api.name} got no movie on ${load.url}")
- continue
- }
-
- validResults = loadLinks(api, load.dataUrl)
- if (!validResults) continue
- }
- is TvSeriesLoadResponse -> {
- val gotNoEpisodes = load.episodes.isEmpty()
- if (gotNoEpisodes) {
- println("Api ${api.name} got no episodes on ${load.url}")
- continue
- }
-
- validResults = loadLinks(api, load.episodes.first().data)
- if (!validResults) continue
- }
- }
- break
- }
- if(!validResults) {
- System.err.println("Api ${api.name} did not load on any")
- }
-
- return validResults
- } catch (e: Exception) {
- if (e.cause is NotImplementedError) {
- Assert.fail("Provider has not implemented .load")
- }
- logError(e)
- return false
- }
+ private fun getAllProviders(): Array {
+ println("Providers: ${APIHolder.allProviders.size}")
+ return APIHolder.allProviders.toTypedArray() //.filter { !it.usesWebView }
}
@Test
@@ -158,7 +60,78 @@ class ExampleInstrumentedTest {
println("Done providersExist")
}
+ @Throws
+ private inline fun testAllLayouts(
+ activity: Activity,
+ vararg layouts: Int
+ ) {
+
+ val bind = T::class.java.methods.first { it.name == "bind" }
+ val inflater = LayoutInflater.from(activity)
+ for (layout in layouts) {
+ val root = inflater.inflate(layout, null, false)
+ bind.invoke(null, root)
+ }
+ }
+
@Test
+ @Throws
+ fun layoutTest() {
+ ActivityScenario.launch(MainActivity::class.java).use { scenario ->
+ scenario.onActivity { activity: MainActivity ->
+ // FragmentHomeHeadBinding and FragmentHomeHeadTvBinding CANT be the same
+ //testAllLayouts(activity, R.layout.fragment_home_head, R.layout.fragment_home_head_tv)
+ //testAllLayouts(activity, R.layout.fragment_home_head, R.layout.fragment_home_head_tv)
+
+ // main cant be tested
+ // testAllLayouts(activity,R.layout.activity_main, R.layout.activity_main_tv)
+ // testAllLayouts(activity,R.layout.activity_main, R.layout.activity_main_tv)
+ //testAllLayouts(activity, R.layout.activity_main_tv)
+
+ testAllLayouts(activity, R.layout.fragment_player,R.layout.fragment_player_tv)
+ testAllLayouts(activity, R.layout.fragment_player,R.layout.fragment_player_tv)
+
+ // testAllLayouts(activity, R.layout.fragment_result,R.layout.fragment_result_tv)
+ // testAllLayouts(activity, R.layout.fragment_result,R.layout.fragment_result_tv)
+
+ testAllLayouts(activity, R.layout.player_custom_layout,R.layout.player_custom_layout_tv, R.layout.trailer_custom_layout)
+ testAllLayouts(activity, R.layout.player_custom_layout,R.layout.player_custom_layout_tv, R.layout.trailer_custom_layout)
+ testAllLayouts(activity, R.layout.player_custom_layout,R.layout.player_custom_layout_tv, R.layout.trailer_custom_layout)
+
+ testAllLayouts(activity, R.layout.repository_item_tv, R.layout.repository_item)
+ testAllLayouts(activity, R.layout.repository_item_tv, R.layout.repository_item)
+
+ testAllLayouts(activity, R.layout.repository_item_tv, R.layout.repository_item)
+ testAllLayouts(activity, R.layout.repository_item_tv, R.layout.repository_item)
+
+ testAllLayouts(activity, R.layout.fragment_home_tv, R.layout.fragment_home)
+ testAllLayouts(activity, R.layout.fragment_home_tv, R.layout.fragment_home)
+
+ testAllLayouts(activity, R.layout.fragment_search_tv, R.layout.fragment_search)
+ testAllLayouts(activity, R.layout.fragment_search_tv, R.layout.fragment_search)
+
+ testAllLayouts(activity, R.layout.home_result_grid_expanded, R.layout.home_result_grid)
+ //testAllLayouts(activity, R.layout.home_result_grid_expanded, R.layout.home_result_grid) ??? fails ???
+
+ testAllLayouts(activity, R.layout.search_result_grid, R.layout.search_result_grid_expanded)
+ testAllLayouts(activity, R.layout.search_result_grid, R.layout.search_result_grid_expanded)
+
+
+ // testAllLayouts(activity, R.layout.home_scroll_view, R.layout.home_scroll_view_tv)
+ // testAllLayouts(activity, R.layout.home_scroll_view, R.layout.home_scroll_view_tv)
+
+ testAllLayouts(activity, R.layout.homepage_parent_tv, R.layout.homepage_parent_emulator, R.layout.homepage_parent)
+ testAllLayouts(activity, R.layout.homepage_parent_tv, R.layout.homepage_parent_emulator, R.layout.homepage_parent)
+ testAllLayouts(activity, R.layout.homepage_parent_tv, R.layout.homepage_parent_emulator, R.layout.homepage_parent)
+
+ testAllLayouts(activity, R.layout.fragment_library_tv, R.layout.fragment_library)
+ testAllLayouts(activity, R.layout.fragment_library_tv, R.layout.fragment_library)
+ }
+ }
+ }
+
+ @Test
+ @Throws(AssertionError::class)
fun providerCorrectData() {
val isoNames = SubtitleHelper.languages.map { it.ISO_639_1 }
Assert.assertFalse("ISO does not contain any languages", isoNames.isNullOrEmpty())
@@ -180,66 +153,20 @@ class ExampleInstrumentedTest {
@Test
fun providerCorrectHomepage() {
runBlocking {
- getAllProviders().apmap { api ->
- if (api.hasMainPage) {
- try {
- val homepage = api.getMainPage()
- when {
- homepage == null -> {
- System.err.println("Homepage provider ${api.name} did not correctly load homepage!")
- }
- homepage.items.isEmpty() -> {
- System.err.println("Homepage provider ${api.name} does not contain any items!")
- }
- homepage.items.any { it.list.isEmpty() } -> {
- System.err.println ("Homepage provider ${api.name} does not have any items on result!")
- }
- }
- } catch (e: Exception) {
- if (e.cause is NotImplementedError) {
- Assert.fail("Provider marked as hasMainPage, while in reality is has not been implemented")
- }
- logError(e)
- }
- }
+ getAllProviders().toList().amap { api ->
+ TestingUtils.testHomepage(api, TestingUtils.Logger())
}
}
println("Done providerCorrectHomepage")
}
-// @Test
-// fun testSingleProvider() {
-// testSingleProviderApi(ThenosProvider())
-// }
-
@Test
- fun providerCorrect() {
+ fun testAllProvidersCorrect() {
runBlocking {
- val invalidProvider = ArrayList>()
- val providers = getAllProviders()
- providers.apmap { api ->
- try {
- println("Trying $api")
- if (testSingleProviderApi(api)) {
- println("Success $api")
- } else {
- System.err.println("Error $api")
- invalidProvider.add(Pair(api, null))
- }
- } catch (e: Exception) {
- logError(e)
- invalidProvider.add(Pair(api, e))
- }
- }
- if(invalidProvider.isEmpty()) {
- println("No Invalid providers! :D")
- } else {
- println("Invalid providers are: ")
- for (provider in invalidProvider) {
- println("${provider.first}")
- }
- }
+ TestingUtils.getDeferredProviderTests(
+ this,
+ getAllProviders(),
+ ) { _, _ -> }
}
- println("Done providerCorrect")
}
}
diff --git a/app/src/debug/ic_launcher-playstore.png b/app/src/debug/ic_launcher-playstore.png
index 3c4e788c..8c374dd9 100644
Binary files a/app/src/debug/ic_launcher-playstore.png and b/app/src/debug/ic_launcher-playstore.png differ
diff --git a/app/src/debug/res/mipmap-hdpi/ic_launcher.png b/app/src/debug/res/mipmap-hdpi/ic_launcher.png
index bf8e595f..c947f526 100644
Binary files a/app/src/debug/res/mipmap-hdpi/ic_launcher.png and b/app/src/debug/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/app/src/debug/res/mipmap-hdpi/ic_launcher_round.png b/app/src/debug/res/mipmap-hdpi/ic_launcher_round.png
index bf8e595f..c947f526 100644
Binary files a/app/src/debug/res/mipmap-hdpi/ic_launcher_round.png and b/app/src/debug/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/app/src/debug/res/mipmap-mdpi/ic_launcher.png b/app/src/debug/res/mipmap-mdpi/ic_launcher.png
index 935b7108..e841896f 100644
Binary files a/app/src/debug/res/mipmap-mdpi/ic_launcher.png and b/app/src/debug/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/app/src/debug/res/mipmap-mdpi/ic_launcher_round.png b/app/src/debug/res/mipmap-mdpi/ic_launcher_round.png
index 935b7108..e841896f 100644
Binary files a/app/src/debug/res/mipmap-mdpi/ic_launcher_round.png and b/app/src/debug/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/app/src/debug/res/mipmap-xhdpi/ic_banner.png b/app/src/debug/res/mipmap-xhdpi/ic_banner.png
index 16c4fdd1..6e23cfcf 100644
Binary files a/app/src/debug/res/mipmap-xhdpi/ic_banner.png and b/app/src/debug/res/mipmap-xhdpi/ic_banner.png differ
diff --git a/app/src/debug/res/mipmap-xhdpi/ic_launcher.png b/app/src/debug/res/mipmap-xhdpi/ic_launcher.png
index d62f3f79..c80f9a10 100644
Binary files a/app/src/debug/res/mipmap-xhdpi/ic_launcher.png and b/app/src/debug/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/app/src/debug/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/debug/res/mipmap-xhdpi/ic_launcher_round.png
index d62f3f79..c80f9a10 100644
Binary files a/app/src/debug/res/mipmap-xhdpi/ic_launcher_round.png and b/app/src/debug/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/app/src/debug/res/mipmap-xxhdpi/ic_launcher.png b/app/src/debug/res/mipmap-xxhdpi/ic_launcher.png
index 38d6ede0..f0b781bb 100644
Binary files a/app/src/debug/res/mipmap-xxhdpi/ic_launcher.png and b/app/src/debug/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/app/src/debug/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/debug/res/mipmap-xxhdpi/ic_launcher_round.png
index 38d6ede0..f0b781bb 100644
Binary files a/app/src/debug/res/mipmap-xxhdpi/ic_launcher_round.png and b/app/src/debug/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png
index 81c5621b..d5fa9d70 100644
Binary files a/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png and b/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_round.png
index 81c5621b..d5fa9d70 100644
Binary files a/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_round.png and b/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 460a47ea..888be999 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,18 +1,27 @@
+ xmlns:tools="http://schemas.android.com/tools">
-
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ tools:targetApi="tiramisu">
+ android:supportsPictureInPicture="true"
+ android:taskAffinity="com.lagradost.cloudstream3.downloadedplayer"
+ android:launchMode="singleTask">
@@ -76,16 +97,20 @@
-->
-
-
-
-
+
+
+
+
+
+
+
+
@@ -103,6 +128,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -116,6 +165,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
@@ -123,13 +187,14 @@
-
+ android:exported="false">
+
@@ -138,6 +203,11 @@
android:name=".ui.ControllerActivity"
android:exported="false" />
+
+
+#include
+#include
+
+#define TAG "CloudStream Crash Handler"
+volatile sig_atomic_t gSignalStatus = 0;
+void handleNativeCrash(int signal) {
+ gSignalStatus = signal;
+}
+
+extern "C" JNIEXPORT void JNICALL
+Java_com_lagradost_cloudstream3_NativeCrashHandler_initNativeCrashHandler(JNIEnv *env, jobject) {
+ #define REGISTER_SIGNAL(X) signal(X, handleNativeCrash);
+ REGISTER_SIGNAL(SIGSEGV)
+ #undef REGISTER_SIGNAL
+}
+
+//extern "C" JNIEXPORT void JNICALL
+//Java_com_lagradost_cloudstream3_NativeCrashHandler_triggerNativeCrash(JNIEnv *env, jobject thiz) {
+// int *p = nullptr;
+// *p = 0;
+//}
+
+extern "C" JNIEXPORT int JNICALL
+Java_com_lagradost_cloudstream3_NativeCrashHandler_getSignalStatus(JNIEnv *env, jobject) {
+ //__android_log_print(ANDROID_LOG_INFO, TAG, "Got signal status %d", gSignalStatus);
+ return gSignalStatus;
+}
\ No newline at end of file
diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png
index 88e4735c..d959673a 100644
Binary files a/app/src/main/ic_launcher-playstore.png and b/app/src/main/ic_launcher-playstore.png differ
diff --git a/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt b/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt
index 93469451..d6f978fe 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt
@@ -4,12 +4,18 @@ import android.app.Activity
import android.app.Application
import android.content.Context
import android.content.ContextWrapper
+import android.content.Intent
import android.widget.Toast
import androidx.fragment.app.Fragment
-import com.google.auto.service.AutoService
+import androidx.fragment.app.FragmentActivity
+import com.lagradost.api.setContext
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall
-import com.lagradost.cloudstream3.utils.AppUtils.openBrowser
+import com.lagradost.cloudstream3.plugins.PluginManager
+import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
+import com.lagradost.cloudstream3.ui.settings.Globals.TV
+import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
+import com.lagradost.cloudstream3.utils.AppContextUtils.openBrowser
import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread
import com.lagradost.cloudstream3.utils.DataStore.getKey
import com.lagradost.cloudstream3.utils.DataStore.getKeys
@@ -17,6 +23,7 @@ import com.lagradost.cloudstream3.utils.DataStore.removeKey
import com.lagradost.cloudstream3.utils.DataStore.removeKeys
import com.lagradost.cloudstream3.utils.DataStore.setKey
import kotlinx.coroutines.runBlocking
+import org.acra.ACRA
import org.acra.ReportField
import org.acra.config.CoreConfiguration
import org.acra.data.CrashReportData
@@ -24,24 +31,29 @@ import org.acra.data.StringFormat
import org.acra.ktx.initAcra
import org.acra.sender.ReportSender
import org.acra.sender.ReportSenderFactory
+import java.io.File
+import java.io.FileNotFoundException
+import java.io.PrintStream
import java.lang.ref.WeakReference
+import java.util.Locale
import kotlin.concurrent.thread
+import kotlin.system.exitProcess
class CustomReportSender : ReportSender {
// Sends all your crashes to google forms
override fun send(context: Context, errorContent: CrashReportData) {
println("Sending report")
val url =
- "https://docs.google.com/forms/u/0/d/e/1FAIpQLSeFmyBChi6HF3IkhTVWPiDXJtxt8W0Hf4Agljm_0-0_QuEYFg/formResponse"
+ "https://docs.google.com/forms/d/e/1FAIpQLSfO4r353BJ79TTY_-t5KWSIJT2xfqcQWY81xjAA1-1N0U2eSg/formResponse"
val data = mapOf(
- "entry.134906550" to errorContent.toJSON()
+ "entry.1993829403" to errorContent.toJSON()
)
thread { // to not run it on main thread
runBlocking {
suspendSafeApiCall {
- val post = app.post(url, data = data)
- println("Report response: $post")
+ app.post(url, data = data)
+ //println("Report response: $post")
}
}
}
@@ -54,7 +66,6 @@ class CustomReportSender : ReportSender {
}
}
-@AutoService(ReportSenderFactory::class)
class CustomSenderFactory : ReportSenderFactory {
override fun create(context: Context, config: CoreConfiguration): ReportSender {
return CustomReportSender()
@@ -65,7 +76,40 @@ class CustomSenderFactory : ReportSenderFactory {
}
}
+class ExceptionHandler(val errorFile: File, val onError: (() -> Unit)) :
+ Thread.UncaughtExceptionHandler {
+ override fun uncaughtException(thread: Thread, error: Throwable) {
+ ACRA.errorReporter.handleException(error)
+ try {
+ PrintStream(errorFile).use { ps ->
+ ps.println("Currently loading extension: ${PluginManager.currentlyLoading ?: "none"}")
+ ps.println("Fatal exception on thread ${thread.name} (${thread.id})")
+ error.printStackTrace(ps)
+ }
+ } catch (ignored: FileNotFoundException) {
+ }
+ try {
+ onError.invoke()
+ } catch (ignored: Exception) {
+ }
+ exitProcess(1)
+ }
+
+}
+
class AcraApplication : Application() {
+
+ override fun onCreate() {
+ super.onCreate()
+ ExceptionHandler(filesDir.resolve("last_error")) {
+ val intent = context!!.packageManager.getLaunchIntentForPackage(context!!.packageName)
+ startActivity(Intent.makeRestartActivityTask(intent!!.component))
+ }.also {
+ exceptionHandler = it
+ Thread.setDefaultUncaughtExceptionHandler(it)
+ }
+ }
+
override fun attachBaseContext(base: Context?) {
super.attachBaseContext(base)
context = base
@@ -75,10 +119,10 @@ class AcraApplication : Application() {
buildConfigClass = BuildConfig::class.java
reportFormat = StringFormat.JSON
- reportContent = arrayOf(
+ reportContent = listOf(
ReportField.BUILD_CONFIG, ReportField.USER_CRASH_DATE,
ReportField.ANDROID_VERSION, ReportField.PHONE_MODEL,
- ReportField.STACK_TRACE
+ ReportField.STACK_TRACE,
)
// removed this due to bug when starting the app, moved it to when it actually crashes
@@ -91,6 +135,8 @@ class AcraApplication : Application() {
}
companion object {
+ var exceptionHandler: ExceptionHandler? = null
+
/** Use to get activity from Context */
tailrec fun Context.getActivity(): Activity? = this as? Activity
?: (this as? ContextWrapper)?.baseContext?.getActivity()
@@ -100,8 +146,17 @@ class AcraApplication : Application() {
get() = _context?.get()
private set(value) {
_context = WeakReference(value)
+ setContext(WeakReference(value))
}
+ fun getKeyClass(path: String, valueType: Class): T? {
+ return context?.getKey(path, valueType)
+ }
+
+ fun setKeyClass(path: String, value: T) {
+ context?.setKey(path, value)
+ }
+
fun removeKeys(folder: String): Int? {
return context?.removeKeys(folder)
}
@@ -148,5 +203,14 @@ class AcraApplication : Application() {
fun openBrowser(url: String, fallbackWebview: Boolean = false, fragment: Fragment? = null) {
context?.openBrowser(url, fallbackWebview, fragment)
}
+
+ /** Will fallback to webview if in TV layout */
+ fun openBrowser(url: String, activity: FragmentActivity?) {
+ openBrowser(
+ url,
+ isLayout(TV or EMULATOR),
+ activity?.supportFragmentManager?.fragments?.lastOrNull()
+ )
+ }
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt
index 0f54770f..ee3a5d12 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt
@@ -1,40 +1,95 @@
package com.lagradost.cloudstream3
+import android.Manifest
import android.app.Activity
import android.app.PictureInPictureParams
import android.content.Context
import android.content.pm.PackageManager
+import android.content.res.Configuration
import android.content.res.Resources
import android.os.Build
-import android.os.Looper
+import android.util.DisplayMetrics
import android.util.Log
-import android.view.*
-import android.widget.TextView
+import android.view.Gravity
+import android.view.KeyEvent
+import android.view.View
+import android.view.View.NO_ID
+import android.view.ViewGroup
import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.MainThread
import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.SearchView
+import androidx.core.content.ContextCompat
+import androidx.core.view.children
import androidx.preference.PreferenceManager
import com.google.android.gms.cast.framework.CastSession
+import com.google.android.material.chip.ChipGroup
+import com.google.android.material.navigationrail.NavigationRailView
+import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
+import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
+import com.lagradost.cloudstream3.MainActivity.Companion.resumeApps
+import com.lagradost.cloudstream3.databinding.ToastBinding
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.ui.player.PlayerEventType
+import com.lagradost.cloudstream3.ui.result.ResultFragment
import com.lagradost.cloudstream3.ui.result.UiText
+import com.lagradost.cloudstream3.ui.settings.Globals.updateTv
+import com.lagradost.cloudstream3.utils.AppContextUtils.isRtl
+import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.Event
import com.lagradost.cloudstream3.utils.UIHelper
import com.lagradost.cloudstream3.utils.UIHelper.hasPIPPermission
import com.lagradost.cloudstream3.utils.UIHelper.shouldShowPIPMode
import com.lagradost.cloudstream3.utils.UIHelper.toPx
-import kotlinx.coroutines.currentCoroutineContext
import org.schabi.newpipe.extractor.NewPipe
-import java.util.*
+import java.lang.ref.WeakReference
+import java.util.Locale
+import kotlin.math.max
+import kotlin.math.min
+
+enum class FocusDirection {
+ Start,
+ End,
+ Up,
+ Down,
+}
object CommonActivity {
+
+ private var _activity: WeakReference? = null
+ var activity
+ get() = _activity?.get()
+ private set(value) {
+ _activity = WeakReference(value)
+ }
+
+ @MainThread
+ fun setActivityInstance(newActivity: Activity?) {
+ activity = newActivity
+ }
+
@MainThread
fun Activity?.getCastSession(): CastSession? {
return (this as MainActivity?)?.mSessionManager?.currentCastSession
}
+ val displayMetrics: DisplayMetrics = Resources.getSystem().displayMetrics
+
+ // screenWidth and screenHeight does always
+ // refer to the screen while in landscape mode
+ val screenWidth: Int
+ get() {
+ return max(displayMetrics.widthPixels, displayMetrics.heightPixels)
+ }
+ val screenHeight: Int
+ get() {
+ return min(displayMetrics.widthPixels, displayMetrics.heightPixels)
+ }
+
+
var canEnterPipMode: Boolean = false
var canShowPipMode: Boolean = false
var isInPIPMode: Boolean = false
@@ -45,9 +100,32 @@ object CommonActivity {
var playerEventListener: ((PlayerEventType) -> Unit)? = null
var keyEventListener: ((Pair) -> Boolean)? = null
+ private var currentToast: Toast? = null
- var currentToast: Toast? = null
+ fun showToast(@StringRes message: Int, duration: Int? = null) {
+ val act = activity ?: return
+ act.runOnUiThread {
+ showToast(act, act.getString(message), duration)
+ }
+ }
+ fun showToast(message: String?, duration: Int? = null) {
+ val act = activity ?: return
+ act.runOnUiThread {
+ showToast(act, message, duration)
+ }
+ }
+
+ fun showToast(message: UiText?, duration: Int? = null) {
+ val act = activity ?: return
+ if (message == null) return
+ act.runOnUiThread {
+ showToast(act, message.asString(act), duration)
+ }
+ }
+
+
+ @MainThread
fun showToast(act: Activity?, text: UiText, duration: Int) {
if (act == null) return
text.asStringNull(act)?.let {
@@ -55,7 +133,9 @@ object CommonActivity {
}
}
- fun showToast(act: Activity?, @StringRes message: Int, duration: Int) {
+ /** duration is Toast.LENGTH_SHORT if null*/
+ @MainThread
+ fun showToast(act: Activity?, @StringRes message: Int, duration: Int? = null) {
if (act == null) return
showToast(act, act.getString(message), duration)
}
@@ -63,6 +143,7 @@ object CommonActivity {
const val TAG = "COMPACT"
/** duration is Toast.LENGTH_SHORT if null*/
+ @MainThread
fun showToast(act: Activity?, message: String?, duration: Int? = null) {
if (act == null || message == null) {
Log.w(TAG, "invalid showToast act = $act message = $message")
@@ -75,33 +156,36 @@ object CommonActivity {
} catch (e: Exception) {
logError(e)
}
+
try {
- val inflater =
- act.getSystemService(AppCompatActivity.LAYOUT_INFLATER_SERVICE) as LayoutInflater
-
- val layout: View = inflater.inflate(
- R.layout.toast,
- act.findViewById(R.id.toast_layout_root) as ViewGroup?
- )
-
- val text = layout.findViewById(R.id.text) as TextView
- text.text = message.trim()
+ val binding = ToastBinding.inflate(act.layoutInflater)
+ binding.text.text = message.trim()
+ // custom toasts are deprecated and won't appear when cs3 sets minSDK to api30 (A11)
val toast = Toast(act)
- toast.setGravity(Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM, 0, 5.toPx)
toast.duration = duration ?: Toast.LENGTH_SHORT
- toast.view = layout
- //https://github.com/PureWriter/ToastCompat
- toast.show()
+ toast.setGravity(Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM, 0, 5.toPx)
+ toast.view = binding.root //fixme Find an alternative using default Toasts since custom toasts are deprecated and won't appear with api30 set as minSDK version.
currentToast = toast
+ toast.show()
+
} catch (e: Exception) {
logError(e)
}
}
+ /**
+ * Not all languages can be fetched from locale with a code.
+ * This map allows sidestepping the default Locale(languageCode)
+ * when setting the app language.
+ **/
+ val appLanguageExceptions = hashMapOf(
+ "zh-rTW" to Locale.TRADITIONAL_CHINESE
+ )
+
fun setLocale(context: Context?, languageCode: String?) {
if (context == null || languageCode == null) return
- val locale = Locale(languageCode)
+ val locale = appLanguageExceptions[languageCode] ?: Locale(languageCode)
val resources: Resources = context.resources
val config = resources.configuration
Locale.setDefault(locale)
@@ -118,18 +202,54 @@ object CommonActivity {
setLocale(this, localeCode)
}
- fun init(act: Activity?) {
- if (act == null) return
+ fun init(act: Activity) {
+ setActivityInstance(act)
+
+ val componentActivity = activity as? ComponentActivity ?: return
+
//https://stackoverflow.com/questions/52594181/how-to-know-if-user-has-disabled-picture-in-picture-feature-permission
//https://developer.android.com/guide/topics/ui/picture-in-picture
canShowPipMode =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && // OS SUPPORT
- act.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) && // HAS FEATURE, MIGHT BE BLOCKED DUE TO POWER DRAIN
- act.hasPIPPermission() // CHECK IF FEATURE IS ENABLED IN SETTINGS
-
- act.updateLocale()
+ componentActivity.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) && // HAS FEATURE, MIGHT BE BLOCKED DUE TO POWER DRAIN
+ componentActivity.hasPIPPermission() // CHECK IF FEATURE IS ENABLED IN SETTINGS
+ componentActivity.updateLocale()
+ componentActivity.updateTv()
NewPipe.init(DownloaderTestImpl.getInstance())
+
+ for (resumeApp in resumeApps) {
+ resumeApp.launcher =
+ componentActivity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ val resultCode = result.resultCode
+ val data = result.data
+ if (resultCode == AppCompatActivity.RESULT_OK && data != null && resumeApp.position != null && resumeApp.duration != null) {
+ val pos = resumeApp.getPosition(data)
+ val dur = resumeApp.getDuration(data)
+ if (dur > 0L && pos > 0L)
+ DataStoreHelper.setViewPos(getKey(resumeApp.lastId), pos, dur)
+ removeKey(resumeApp.lastId)
+ ResultFragment.updateUI()
+ }
+ }
+ }
+
+ // Ask for notification permissions on Android 13
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
+ ContextCompat.checkSelfPermission(
+ componentActivity,
+ Manifest.permission.POST_NOTIFICATIONS
+ ) != PackageManager.PERMISSION_GRANTED
+ ) {
+ val requestPermissionLauncher = componentActivity.registerForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) { isGranted: Boolean ->
+ Log.d(TAG, "Notification permission: $isGranted")
+ }
+ requestPermissionLauncher.launch(
+ Manifest.permission.POST_NOTIFICATIONS
+ )
+ }
}
private fun Activity.enterPIPMode() {
@@ -157,28 +277,57 @@ object CommonActivity {
}
}
+ fun updateTheme(act: Activity) {
+ val settingsManager = PreferenceManager.getDefaultSharedPreferences(act)
+ if (settingsManager
+ .getString(act.getString(R.string.app_theme_key), "AmoledLight") == "System"
+ && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ loadThemes(act)
+ }
+ }
+
+ private fun mapSystemTheme(act: Activity): Int {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ val currentNightMode =
+ act.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
+ return when (currentNightMode) {
+ Configuration.UI_MODE_NIGHT_NO -> R.style.LightMode // Night mode is not active, we're using the light theme
+ else -> R.style.AppTheme // Night mode is active, we're using dark theme
+ }
+ } else {
+ return R.style.AppTheme
+ }
+ }
+
fun loadThemes(act: Activity?) {
if (act == null) return
val settingsManager = PreferenceManager.getDefaultSharedPreferences(act)
val currentTheme =
when (settingsManager.getString(act.getString(R.string.app_theme_key), "AmoledLight")) {
+ "System" -> mapSystemTheme(act)
"Black" -> R.style.AppTheme
"Light" -> R.style.LightMode
"Amoled" -> R.style.AmoledMode
"AmoledLight" -> R.style.AmoledModeLight
+ "Monet" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
+ R.style.MonetMode else R.style.AppTheme
+
else -> R.style.AppTheme
}
val currentOverlayTheme =
when (settingsManager.getString(act.getString(R.string.primary_color_key), "Normal")) {
"Normal" -> R.style.OverlayPrimaryColorNormal
+ "DandelionYellow" -> R.style.OverlayPrimaryColorDandelionYellow
"CarnationPink" -> R.style.OverlayPrimaryColorCarnationPink
+ "Orange" -> R.style.OverlayPrimaryColorOrange
"DarkGreen" -> R.style.OverlayPrimaryColorDarkGreen
"Maroon" -> R.style.OverlayPrimaryColorMaroon
"NavyBlue" -> R.style.OverlayPrimaryColorNavyBlue
"Grey" -> R.style.OverlayPrimaryColorGrey
"White" -> R.style.OverlayPrimaryColorWhite
+ "CoolBlue" -> R.style.OverlayPrimaryColorCoolBlue
"Brown" -> R.style.OverlayPrimaryColorBrown
"Purple" -> R.style.OverlayPrimaryColorPurple
"Green" -> R.style.OverlayPrimaryColorGreen
@@ -187,6 +336,13 @@ object CommonActivity {
"Banana" -> R.style.OverlayPrimaryColorBanana
"Party" -> R.style.OverlayPrimaryColorParty
"Pink" -> R.style.OverlayPrimaryColorPink
+ "Lavender" -> R.style.OverlayPrimaryColorLavender
+ "Monet" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
+ R.style.OverlayPrimaryColorMonet else R.style.OverlayPrimaryColorNormal
+
+ "Monet2" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
+ R.style.OverlayPrimaryColorMonetTwo else R.style.OverlayPrimaryColorNormal
+
else -> R.style.OverlayPrimaryColorNormal
}
act.theme.applyStyle(currentTheme, true)
@@ -198,120 +354,207 @@ object CommonActivity {
) // THEME IS SET BEFORE VIEW IS CREATED TO APPLY THE THEME TO THE MAIN VIEW
}
- private fun getNextFocus(
- act: Activity?,
+ /** because we want closes find, aka when multiple have the same id, we go to parent
+ until the correct one is found */
+ private fun localLook(from: View, id: Int): View? {
+ if (id == NO_ID) return null
+ var currentLook: View = from
+ // limit to 15 look depth
+ for (i in 0..15) {
+ currentLook.findViewById(id)?.let { return it }
+ currentLook = (currentLook.parent as? View) ?: break
+ }
+ return null
+ }
+ /*var currentLook: View = view
+ while (true) {
+ val tmpNext = currentLook.findViewById(nextId)
+ if (tmpNext != null) {
+ next = tmpNext
+ break
+ }
+ currentLook = currentLook.parent as? View ?: break
+ }*/
+
+ private fun View.hasContent(): Boolean {
+ return isShown && when (this) {
+ //is RecyclerView -> this.childCount > 0
+ is ViewGroup -> this.childCount > 0
+ else -> true
+ }
+ }
+
+ /** skips the initial stage of searching for an id using the view, see getNextFocus for specification */
+ fun continueGetNextFocus(
+ root: Any?,
+ view: View,
+ direction: FocusDirection,
+ nextId: Int,
+ depth: Int = 0
+ ): View? {
+ if (nextId == NO_ID) return null
+
+ // do an initial search for the view, in case the localLook is too deep we can use this as
+ // an early break and backup view
+ var next =
+ when (root) {
+ is Activity -> root.findViewById(nextId)
+ is View -> root.rootView.findViewById(nextId)
+ else -> null
+ } ?: return null
+
+ next = localLook(view, nextId) ?: next
+ val shown = next.hasContent()
+
+ // if cant focus but visible then break and let android decide
+ // the exception if is the view is a parent and has children that wants focus
+ val hasChildrenThatWantsFocus = (next as? ViewGroup)?.let { parent ->
+ parent.descendantFocusability == ViewGroup.FOCUS_AFTER_DESCENDANTS && parent.childCount > 0
+ } ?: false
+ if (!next.isFocusable && shown && !hasChildrenThatWantsFocus) return null
+
+ // if not shown then continue because we will "skip" over views to get to a replacement
+ if (!shown) {
+ // we don't want a while true loop, so we let android decide if we find a recursive view
+ if (next == view) return null
+ return getNextFocus(root, next, direction, depth + 1)
+ }
+
+ (when (next) {
+ is ChipGroup -> {
+ next.children.firstOrNull { it.isFocusable && it.isShown }
+ }
+
+ is NavigationRailView -> {
+ next.findViewById(next.selectedItemId) ?: next.findViewById(R.id.navigation_home)
+ }
+
+ else -> null
+ })?.let {
+ return it
+ }
+
+ // nothing wrong with the view found, return it
+ return next
+ }
+
+ /** recursively looks for a next focus up to a depth of 10,
+ * this is used to override the normal shit focus system
+ * because this application has a lot of invisible views that messes with some tv devices*/
+ fun getNextFocus(
+ root: Any?,
view: View?,
direction: FocusDirection,
depth: Int = 0
- ): Int? {
- if (view == null || depth >= 10 || act == null) {
+ ): View? {
+ // if input is invalid let android decide + depth test to not crash if loop is found
+ if (view == null || depth >= 10 || root == null) {
return null
}
- val nextId = when (direction) {
- FocusDirection.Left -> {
- view.nextFocusLeftId
+ var nextId = when (direction) {
+ FocusDirection.Start -> {
+ if (view.isRtl())
+ view.nextFocusRightId
+ else
+ view.nextFocusLeftId
}
+
FocusDirection.Up -> {
view.nextFocusUpId
}
- FocusDirection.Right -> {
- view.nextFocusRightId
+
+ FocusDirection.End -> {
+ if (view.isRtl())
+ view.nextFocusLeftId
+ else
+ view.nextFocusRightId
}
+
FocusDirection.Down -> {
view.nextFocusDownId
}
}
- return if (nextId != -1) {
- val next = act.findViewById(nextId)
- //println("NAME: ${next.accessibilityClassName} | ${next?.isShown}" )
-
- if (next?.isShown == false) {
- getNextFocus(act, next, direction, depth + 1)
- } else {
- if (depth == 0) {
- null
- } else {
- nextId
- }
- }
- } else {
- null
+ if (nextId == NO_ID) {
+ // if not specified then use forward id
+ nextId = view.nextFocusForwardId
+ // if view is still not found to next focus then return and let android decide
+ if (nextId == NO_ID)
+ return null
}
+ return continueGetNextFocus(root, view, direction, nextId, depth)
}
- enum class FocusDirection {
- Left,
- Right,
- Up,
- Down,
- }
fun onKeyDown(act: Activity?, keyCode: Int, event: KeyEvent?) {
- //println("Keycode: $keyCode")
- //showToast(
- // this,
- // "Got Keycode $keyCode | ${KeyEvent.keyCodeToString(keyCode)} \n ${event?.action}",
- // Toast.LENGTH_LONG
- //)
-
- // Tested keycodes on remote:
- // KeyEvent.KEYCODE_MEDIA_FAST_FORWARD
- // KeyEvent.KEYCODE_MEDIA_REWIND
- // KeyEvent.KEYCODE_MENU
- // KeyEvent.KEYCODE_MEDIA_NEXT
- // KeyEvent.KEYCODE_MEDIA_PREVIOUS
- // KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE
// 149 keycode_numpad 5
when (keyCode) {
KeyEvent.KEYCODE_FORWARD, KeyEvent.KEYCODE_D, KeyEvent.KEYCODE_MEDIA_SKIP_FORWARD, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
PlayerEventType.SeekForward
}
+
KeyEvent.KEYCODE_A, KeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD, KeyEvent.KEYCODE_MEDIA_REWIND -> {
PlayerEventType.SeekBack
}
+
KeyEvent.KEYCODE_MEDIA_NEXT, KeyEvent.KEYCODE_BUTTON_R1, KeyEvent.KEYCODE_N -> {
PlayerEventType.NextEpisode
}
+
KeyEvent.KEYCODE_MEDIA_PREVIOUS, KeyEvent.KEYCODE_BUTTON_L1, KeyEvent.KEYCODE_B -> {
PlayerEventType.PrevEpisode
}
+
KeyEvent.KEYCODE_MEDIA_PAUSE -> {
PlayerEventType.Pause
}
+
KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_BUTTON_START -> {
PlayerEventType.Play
}
- KeyEvent.KEYCODE_L, KeyEvent.KEYCODE_NUMPAD_7 -> {
+
+ KeyEvent.KEYCODE_L, KeyEvent.KEYCODE_NUMPAD_7, KeyEvent.KEYCODE_7 -> {
PlayerEventType.Lock
}
+
KeyEvent.KEYCODE_H, KeyEvent.KEYCODE_MENU -> {
PlayerEventType.ToggleHide
}
+
KeyEvent.KEYCODE_M, KeyEvent.KEYCODE_VOLUME_MUTE -> {
PlayerEventType.ToggleMute
}
- KeyEvent.KEYCODE_S, KeyEvent.KEYCODE_NUMPAD_9 -> {
+
+ KeyEvent.KEYCODE_S, KeyEvent.KEYCODE_NUMPAD_9, KeyEvent.KEYCODE_9 -> {
PlayerEventType.ShowMirrors
}
// OpenSubtitles shortcut
- KeyEvent.KEYCODE_O, KeyEvent.KEYCODE_NUMPAD_8 -> {
+ KeyEvent.KEYCODE_O, KeyEvent.KEYCODE_NUMPAD_8, KeyEvent.KEYCODE_8 -> {
PlayerEventType.SearchSubtitlesOnline
}
- KeyEvent.KEYCODE_E, KeyEvent.KEYCODE_NUMPAD_3 -> {
+
+ KeyEvent.KEYCODE_E, KeyEvent.KEYCODE_NUMPAD_3, KeyEvent.KEYCODE_3 -> {
PlayerEventType.ShowSpeed
}
- KeyEvent.KEYCODE_R, KeyEvent.KEYCODE_NUMPAD_0 -> {
+
+ KeyEvent.KEYCODE_R, KeyEvent.KEYCODE_NUMPAD_0, KeyEvent.KEYCODE_0 -> {
PlayerEventType.Resize
}
- KeyEvent.KEYCODE_C, KeyEvent.KEYCODE_NUMPAD_4 -> {
+
+ KeyEvent.KEYCODE_C, KeyEvent.KEYCODE_NUMPAD_4, KeyEvent.KEYCODE_4 -> {
PlayerEventType.SkipOp
}
+
+ KeyEvent.KEYCODE_V, KeyEvent.KEYCODE_NUMPAD_5, KeyEvent.KEYCODE_5 -> {
+ PlayerEventType.SkipCurrentChapter
+ }
+
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_P, KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_NUMPAD_ENTER, KeyEvent.KEYCODE_ENTER -> { // space is not captured due to navigation
PlayerEventType.PlayPauseToggle
}
+
else -> null
}?.let { playerEvent ->
playerEventListener?.invoke(playerEvent)
@@ -324,67 +567,67 @@ object CommonActivity {
//}
}
+ /** overrides focus and custom key events */
fun dispatchKeyEvent(act: Activity?, event: KeyEvent?): Boolean? {
if (act == null) return null
+ val currentFocus = act.currentFocus
+
event?.keyCode?.let { keyCode ->
- when (event.action) {
- KeyEvent.ACTION_DOWN -> {
- if (act.currentFocus != null) {
- val next = when (keyCode) {
- KeyEvent.KEYCODE_DPAD_LEFT -> getNextFocus(
- act,
- act.currentFocus,
- FocusDirection.Left
- )
- KeyEvent.KEYCODE_DPAD_RIGHT -> getNextFocus(
- act,
- act.currentFocus,
- FocusDirection.Right
- )
- KeyEvent.KEYCODE_DPAD_UP -> getNextFocus(
- act,
- act.currentFocus,
- FocusDirection.Up
- )
- KeyEvent.KEYCODE_DPAD_DOWN -> getNextFocus(
- act,
- act.currentFocus,
- FocusDirection.Down
- )
+ if (currentFocus == null || event.action != KeyEvent.ACTION_DOWN) return@let
+ val nextView = when (keyCode) {
+ KeyEvent.KEYCODE_DPAD_LEFT -> getNextFocus(
+ act,
+ currentFocus,
+ FocusDirection.Start
+ )
- else -> null
- }
+ KeyEvent.KEYCODE_DPAD_RIGHT -> getNextFocus(
+ act,
+ currentFocus,
+ FocusDirection.End
+ )
- if (next != null && next != -1) {
- val nextView = act.findViewById(next)
- if (nextView != null) {
- nextView.requestFocus()
- keyEventListener?.invoke(Pair(event, true))
- return true
- }
- }
+ KeyEvent.KEYCODE_DPAD_UP -> getNextFocus(
+ act,
+ currentFocus,
+ FocusDirection.Up
+ )
- when (keyCode) {
- KeyEvent.KEYCODE_DPAD_CENTER -> {
- if (act.currentFocus is SearchView || act.currentFocus is SearchView.SearchAutoComplete) {
- UIHelper.showInputMethod(act.currentFocus?.findFocus())
- }
- }
- }
- }
- //println("Keycode: $keyCode")
- //showToast(
- // this,
- // "Got Keycode $keyCode | ${KeyEvent.keyCodeToString(keyCode)} \n ${event?.action}",
- // Toast.LENGTH_LONG
- //)
- }
+ KeyEvent.KEYCODE_DPAD_DOWN -> getNextFocus(
+ act,
+ currentFocus,
+ FocusDirection.Down
+ )
+
+ else -> null
}
+ // println("NEXT FOCUS : $nextView")
+ if (nextView != null) {
+ nextView.requestFocus()
+ keyEventListener?.invoke(Pair(event, true))
+ return true
+ }
+
+ if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER &&
+ (act.currentFocus is SearchView || act.currentFocus is SearchView.SearchAutoComplete)
+ ) {
+ UIHelper.showInputMethod(act.currentFocus?.findFocus())
+ }
+
+ //println("Keycode: $keyCode")
+ //showToast(
+ // this,
+ // "Got Keycode $keyCode | ${KeyEvent.keyCodeToString(keyCode)} \n ${event?.action}",
+ // Toast.LENGTH_LONG
+ //)
+
}
+ // if someone else want to override the focus then don't handle the event as it is already
+ // consumed. used in video player
if (keyEventListener?.invoke(Pair(event, false)) == true) {
return true
}
return null
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/DownloaderTestImpl.kt b/app/src/main/java/com/lagradost/cloudstream3/DownloaderTestImpl.kt
index 379a91e4..8da7ca38 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/DownloaderTestImpl.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/DownloaderTestImpl.kt
@@ -2,6 +2,7 @@ package com.lagradost.cloudstream3
import okhttp3.OkHttpClient
import okhttp3.RequestBody
+import okhttp3.RequestBody.Companion.toRequestBody
import org.schabi.newpipe.extractor.downloader.Downloader
import org.schabi.newpipe.extractor.downloader.Request
import org.schabi.newpipe.extractor.downloader.Response
@@ -10,7 +11,7 @@ import java.util.concurrent.TimeUnit
class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Downloader() {
- private val client: OkHttpClient
+ private val client: OkHttpClient = builder.readTimeout(30, TimeUnit.SECONDS).build()
override fun execute(request: Request): Response {
val httpMethod: String = request.httpMethod()
val url: String = request.url()
@@ -18,7 +19,7 @@ class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Do
val dataToSend: ByteArray? = request.dataToSend()
var requestBody: RequestBody? = null
if (dataToSend != null) {
- requestBody = RequestBody.create(null, dataToSend)
+ requestBody = dataToSend.toRequestBody(null, 0, dataToSend.size)
}
val requestBuilder: okhttp3.Request.Builder = okhttp3.Request.Builder()
.method(httpMethod, requestBody).url(url)
@@ -50,7 +51,7 @@ class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Do
companion object {
private const val USER_AGENT =
- "Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0"
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36"
private var instance: DownloaderTestImpl? = null
/**
@@ -73,8 +74,4 @@ class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Do
return instance
}
}
-
- init {
- client = builder.readTimeout(30, TimeUnit.SECONDS).build()
- }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/HeaderDecorationBindingAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/HeaderDecorationBindingAdapter.kt
new file mode 100644
index 00000000..045a7963
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/HeaderDecorationBindingAdapter.kt
@@ -0,0 +1,11 @@
+package com.lagradost.cloudstream3
+
+import android.view.LayoutInflater
+import androidx.annotation.LayoutRes
+import androidx.recyclerview.widget.RecyclerView
+import com.lagradost.cloudstream3.ui.HeaderViewDecoration
+
+fun setHeaderDecoration(view: RecyclerView, @LayoutRes headerViewRes: Int) {
+ val headerView = LayoutInflater.from(view.context).inflate(headerViewRes, null)
+ view.addItemDecoration(HeaderViewDecoration(headerView))
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt
index 49864e65..5408d2a8 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt
@@ -1,18 +1,38 @@
package com.lagradost.cloudstream3
+import android.animation.ValueAnimator
import android.content.ComponentName
+import android.content.Context
import android.content.Intent
import android.content.res.ColorStateList
import android.content.res.Configuration
+import android.graphics.Rect
+import android.net.Uri
+import android.os.Build
import android.os.Bundle
+import android.util.AttributeSet
import android.util.Log
import android.view.KeyEvent
import android.view.Menu
import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
import android.view.WindowManager
+import android.widget.Toast
+import androidx.activity.OnBackPressedCallback
+import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.IdRes
+import androidx.annotation.MainThread
+import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.core.view.children
+import androidx.core.view.isGone
+import androidx.core.view.isInvisible
import androidx.core.view.isVisible
+import androidx.core.view.marginStart
+import androidx.fragment.app.FragmentActivity
+import androidx.lifecycle.ViewModelProvider
import androidx.navigation.NavController
import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hierarchy
@@ -21,120 +41,440 @@ import androidx.navigation.NavOptions
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.ui.setupWithNavController
import androidx.preference.PreferenceManager
-import com.fasterxml.jackson.databind.DeserializationFeature
-import com.fasterxml.jackson.databind.ObjectMapper
-import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
-import com.google.android.gms.cast.framework.*
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.LinearSnapHelper
+import androidx.recyclerview.widget.RecyclerView
+import com.google.android.gms.cast.framework.CastContext
+import com.google.android.gms.cast.framework.Session
+import com.google.android.gms.cast.framework.SessionManager
+import com.google.android.gms.cast.framework.SessionManagerListener
+import com.google.android.material.bottomnavigation.BottomNavigationView
+import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.navigationrail.NavigationRailView
+import com.google.android.material.snackbar.Snackbar
+import com.google.common.collect.Comparators.min
import com.jaredrummler.android.colorpicker.ColorPickerDialogListener
import com.lagradost.cloudstream3.APIHolder.allProviders
import com.lagradost.cloudstream3.APIHolder.apis
-import com.lagradost.cloudstream3.APIHolder.getApiDubstatusSettings
import com.lagradost.cloudstream3.APIHolder.initAll
-import com.lagradost.cloudstream3.APIHolder.updateHasTrailers
+import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
+import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
+import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.CommonActivity.loadThemes
import com.lagradost.cloudstream3.CommonActivity.onColorSelectedEvent
import com.lagradost.cloudstream3.CommonActivity.onDialogDismissedEvent
import com.lagradost.cloudstream3.CommonActivity.onUserLeaveHint
+import com.lagradost.cloudstream3.CommonActivity.screenHeight
+import com.lagradost.cloudstream3.CommonActivity.setActivityInstance
import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.CommonActivity.updateLocale
+import com.lagradost.cloudstream3.CommonActivity.updateTheme
+import com.lagradost.cloudstream3.databinding.ActivityMainBinding
+import com.lagradost.cloudstream3.databinding.ActivityMainTvBinding
+import com.lagradost.cloudstream3.databinding.BottomResultviewPreviewBinding
+import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.mvvm.logError
+import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
+import com.lagradost.cloudstream3.mvvm.observe
+import com.lagradost.cloudstream3.mvvm.observeNullable
import com.lagradost.cloudstream3.network.initClient
import com.lagradost.cloudstream3.plugins.PluginManager
+import com.lagradost.cloudstream3.plugins.PluginManager.loadAllOnlinePlugins
import com.lagradost.cloudstream3.plugins.PluginManager.loadSinglePlugin
import com.lagradost.cloudstream3.receivers.VideoDownloadRestartReceiver
+import com.lagradost.cloudstream3.services.SubscriptionWorkManager
+import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING
+import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_PLAYER
+import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_REPO
+import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_RESUME_WATCHING
+import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_SEARCH
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.OAuth2Apis
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.accountManagers
-import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appString
-import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringRepo
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.inAppAuths
+import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.localListApi
+import com.lagradost.cloudstream3.syncproviders.SyncAPI
import com.lagradost.cloudstream3.ui.APIRepository
+import com.lagradost.cloudstream3.ui.SyncWatchType
+import com.lagradost.cloudstream3.ui.WatchType
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_NAVIGATE_TO
-import com.lagradost.cloudstream3.ui.result.ResultFragment
+import com.lagradost.cloudstream3.ui.home.HomeViewModel
+import com.lagradost.cloudstream3.ui.library.LibraryViewModel
+import com.lagradost.cloudstream3.ui.player.BasicLink
+import com.lagradost.cloudstream3.ui.player.GeneratorPlayer
+import com.lagradost.cloudstream3.ui.player.LinkGenerator
+import com.lagradost.cloudstream3.ui.result.LinearListLayout
+import com.lagradost.cloudstream3.ui.result.ResultViewModel2
+import com.lagradost.cloudstream3.ui.result.START_ACTION_RESUME_LATEST
+import com.lagradost.cloudstream3.ui.result.SyncViewModel
+import com.lagradost.cloudstream3.ui.result.setImage
+import com.lagradost.cloudstream3.ui.result.setText
+import com.lagradost.cloudstream3.ui.result.setTextHtml
+import com.lagradost.cloudstream3.ui.result.txt
+import com.lagradost.cloudstream3.ui.search.SearchFragment
import com.lagradost.cloudstream3.ui.search.SearchResultBuilder
-import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isEmulatorSettings
-import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings
+import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
+import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
+import com.lagradost.cloudstream3.ui.settings.Globals.TV
+import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
+import com.lagradost.cloudstream3.ui.settings.Globals.updateTv
import com.lagradost.cloudstream3.ui.settings.SettingsGeneral
import com.lagradost.cloudstream3.ui.setup.HAS_DONE_SETUP_KEY
import com.lagradost.cloudstream3.ui.setup.SetupFragmentExtensions
-import com.lagradost.cloudstream3.utils.AppUtils.isCastApiAvailable
-import com.lagradost.cloudstream3.utils.AppUtils.loadCache
-import com.lagradost.cloudstream3.utils.AppUtils.loadRepository
-import com.lagradost.cloudstream3.utils.AppUtils.loadResult
+import com.lagradost.cloudstream3.utils.ApkInstaller
+import com.lagradost.cloudstream3.utils.AppContextUtils.getApiDubstatusSettings
+import com.lagradost.cloudstream3.utils.AppContextUtils.html
+import com.lagradost.cloudstream3.utils.AppContextUtils.isCastApiAvailable
+import com.lagradost.cloudstream3.utils.AppContextUtils.isLtr
+import com.lagradost.cloudstream3.utils.AppContextUtils.isNetworkAvailable
+import com.lagradost.cloudstream3.utils.AppContextUtils.isRtl
+import com.lagradost.cloudstream3.utils.AppContextUtils.loadCache
+import com.lagradost.cloudstream3.utils.AppContextUtils.loadRepository
+import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult
+import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult
+import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
+import com.lagradost.cloudstream3.utils.AppContextUtils.updateHasTrailers
+import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
+import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback
+import com.lagradost.cloudstream3.utils.BackupUtils.backup
import com.lagradost.cloudstream3.utils.BackupUtils.setUpBackup
+import com.lagradost.cloudstream3.utils.BiometricAuthenticator.BiometricCallback
+import com.lagradost.cloudstream3.utils.BiometricAuthenticator.biometricPrompt
+import com.lagradost.cloudstream3.utils.BiometricAuthenticator.deviceHasPasswordPinLock
+import com.lagradost.cloudstream3.utils.BiometricAuthenticator.isAuthEnabled
+import com.lagradost.cloudstream3.utils.BiometricAuthenticator.promptInfo
+import com.lagradost.cloudstream3.utils.BiometricAuthenticator.startBiometricAuthentication
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
+import com.lagradost.cloudstream3.utils.Coroutines.main
import com.lagradost.cloudstream3.utils.DataStore.getKey
-import com.lagradost.cloudstream3.utils.DataStore.removeKey
import com.lagradost.cloudstream3.utils.DataStore.setKey
+import com.lagradost.cloudstream3.utils.DataStoreHelper
+import com.lagradost.cloudstream3.utils.DataStoreHelper.accounts
import com.lagradost.cloudstream3.utils.DataStoreHelper.migrateResumeWatching
-import com.lagradost.cloudstream3.utils.DataStoreHelper.setViewPos
import com.lagradost.cloudstream3.utils.Event
-import com.lagradost.cloudstream3.utils.IOnBackPressed
import com.lagradost.cloudstream3.utils.InAppUpdater.Companion.runAutoUpdate
+import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
+import com.lagradost.cloudstream3.utils.SnackbarHelper.showSnackbar
import com.lagradost.cloudstream3.utils.UIHelper.changeStatusBarState
import com.lagradost.cloudstream3.utils.UIHelper.checkWrite
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
+import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
import com.lagradost.cloudstream3.utils.UIHelper.getResourceColor
import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
import com.lagradost.cloudstream3.utils.UIHelper.navigate
import com.lagradost.cloudstream3.utils.UIHelper.requestRW
+import com.lagradost.cloudstream3.utils.UIHelper.toPx
import com.lagradost.cloudstream3.utils.USER_PROVIDER_API
import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API
-import com.lagradost.nicehttp.Requests
-import com.lagradost.nicehttp.ResponseParser
-import kotlinx.android.synthetic.main.activity_main.*
-import kotlinx.android.synthetic.main.fragment_result_swipe.*
+import com.lagradost.cloudstream3.utils.fcast.FcastManager
+import com.lagradost.safefile.SafeFile
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
import java.io.File
+import java.lang.ref.WeakReference
import java.net.URI
-import kotlin.reflect.KClass
+import java.net.URLDecoder
+import java.nio.charset.Charset
+import kotlin.math.abs
+import kotlin.math.absoluteValue
+import kotlin.system.exitProcess
+//https://github.com/videolan/vlc-android/blob/3706c4be2da6800b3d26344fc04fab03ffa4b860/application/vlc-android/src/org/videolan/vlc/gui/video/VideoPlayerActivity.kt#L1898
+//https://wiki.videolan.org/Android_Player_Intents/
-const val VLC_PACKAGE = "org.videolan.vlc"
-const val VLC_INTENT_ACTION_RESULT = "org.videolan.vlc.player.result"
-val VLC_COMPONENT: ComponentName =
- ComponentName(VLC_PACKAGE, "org.videolan.vlc.gui.video.VideoPlayerActivity")
-const val VLC_REQUEST_CODE = 42
+//https://github.com/mpv-android/mpv-android/blob/0eb3cdc6f1632636b9c30d52ec50e4b017661980/app/src/main/java/is/xyz/mpv/MPVActivity.kt#L904
+//https://mpv-android.github.io/mpv-android/intent.html
-const val VLC_FROM_START = -1
-const val VLC_FROM_PROGRESS = -2
-const val VLC_EXTRA_POSITION_OUT = "extra_position"
-const val VLC_EXTRA_DURATION_OUT = "extra_duration"
-const val VLC_LAST_ID_KEY = "vlc_last_open_id"
+// https://www.webvideocaster.com/integrations
-// Short name for requests client to make it nicer to use
+//https://github.com/jellyfin/jellyfin-android/blob/6cbf0edf84a3da82347c8d59b5d5590749da81a9/app/src/main/java/org/jellyfin/mobile/bridge/ExternalPlayer.kt#L225
-var app = Requests(responseParser = object : ResponseParser {
- val mapper: ObjectMapper = jacksonObjectMapper().configure(
- DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,
- false
- )
-
- override fun parse(text: String, kClass: KClass): T {
- return mapper.readValue(text, kClass.java)
- }
-
- override fun parseSafe(text: String, kClass: KClass): T? {
- return try {
- mapper.readValue(text, kClass.java)
- } catch (e: Exception) {
- null
- }
- }
-
- override fun writeValueAsString(obj: Any): String {
- return mapper.writeValueAsString(obj)
- }
-}).apply {
- defaultHeaders = mapOf("user-agent" to USER_AGENT)
-}
-
-class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
+class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCallback {
companion object {
+ const val VLC_PACKAGE = "org.videolan.vlc"
+ const val MPV_PACKAGE = "is.xyz.mpv"
+ const val WEB_VIDEO_CAST_PACKAGE = "com.instantbits.cast.webvideo"
+
+ val VLC_COMPONENT = ComponentName(VLC_PACKAGE, "$VLC_PACKAGE.gui.video.VideoPlayerActivity")
+ val MPV_COMPONENT = ComponentName(MPV_PACKAGE, "$MPV_PACKAGE.MPVActivity")
+
+ //TODO REFACTOR AF
+ open class ResultResume(
+ val packageString: String,
+ val action: String = Intent.ACTION_VIEW,
+ val position: String? = null,
+ val duration: String? = null,
+ var launcher: ActivityResultLauncher? = null,
+ ) {
+ val defaultTime = -1L
+
+ val lastId get() = "${packageString}_last_open_id"
+ suspend fun launch(id: Int?, callback: suspend Intent.() -> Unit) {
+ val intent = Intent(action)
+
+ if (id != null)
+ setKey(lastId, id)
+ else
+ removeKey(lastId)
+
+ intent.setPackage(packageString)
+ callback.invoke(intent)
+ launcher?.launch(intent)
+ }
+
+ open fun getPosition(intent: Intent?): Long {
+ return defaultTime
+ }
+
+ open fun getDuration(intent: Intent?): Long {
+ return defaultTime
+ }
+ }
+
+ val VLC = object : ResultResume(
+ VLC_PACKAGE,
+ // Android 13 intent restrictions fucks up specifically launching the VLC player
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
+ "org.videolan.vlc.player.result"
+ } else {
+ Intent.ACTION_VIEW
+ },
+ "extra_position",
+ "extra_duration",
+ ) {
+ override fun getPosition(intent: Intent?): Long {
+ return intent?.getLongExtra(this.position, defaultTime) ?: defaultTime
+ }
+
+ override fun getDuration(intent: Intent?): Long {
+ return intent?.getLongExtra(this.duration, defaultTime) ?: defaultTime
+ }
+ }
+
+ val MPV = object : ResultResume(
+ MPV_PACKAGE,
+ //"is.xyz.mpv.MPVActivity.result", // resume not working :pensive:
+ position = "position",
+ duration = "duration",
+ ) {
+ override fun getPosition(intent: Intent?): Long {
+ return intent?.getIntExtra(this.position, defaultTime.toInt())?.toLong()
+ ?: defaultTime
+ }
+
+ override fun getDuration(intent: Intent?): Long {
+ return intent?.getIntExtra(this.duration, defaultTime.toInt())?.toLong()
+ ?: defaultTime
+ }
+ }
+
+ val WEB_VIDEO = ResultResume(WEB_VIDEO_CAST_PACKAGE)
+
+ val resumeApps = arrayOf(
+ VLC, MPV, WEB_VIDEO
+ )
+
+
const val TAG = "MAINACT"
+ const val ANIMATED_OUTLINE: Boolean = false
+ var lastError: String? = null
+
+ private const val FILE_DELETE_KEY = "FILES_TO_DELETE_KEY"
+
+ /**
+ * Transient files to delete on application exit.
+ * Deletes files on onDestroy().
+ */
+ private var filesToDelete: Set
+ // This needs to be persistent because the application may exit without calling onDestroy.
+ get() = getKey>(FILE_DELETE_KEY) ?: setOf()
+ private set(value) = setKey(FILE_DELETE_KEY, value)
+
+ /**
+ * Add file to delete on Exit.
+ */
+ fun deleteFileOnExit(file: File) {
+ filesToDelete = filesToDelete + file.path
+ }
+
+ /**
+ * Setting this will automatically enter the query in the search
+ * next time the search fragment is opened.
+ * This variable will clear itself after one use. Null does nothing.
+ *
+ * This is a very bad solution but I was unable to find a better one.
+ **/
+ var nextSearchQuery: String? = null
+
+ /**
+ * Fires every time a new batch of plugins have been loaded, no guarantee about how often this is run and on which thread
+ * Boolean signifies if stuff should be force reloaded (true if force reload, false if reload when necessary).
+ *
+ * The force reloading are used for plugin development to instantly reload the page on deployWithAdb
+ * */
val afterPluginsLoadedEvent = Event()
val mainPluginsLoadedEvent =
Event() // homepage api, used to speed up time to load for homepage
val afterRepositoryLoadedEvent = Event()
+
+ // kinda shitty solution, but cant com main->home otherwise for popups
+ val bookmarksUpdatedEvent = Event()
+
+ /**
+ * Used by DataStoreHelper to fully reload home when switching accounts
+ */
+ val reloadHomeEvent = Event()
+
+ /**
+ * Used by DataStoreHelper to fully reload library when switching accounts
+ */
+ val reloadLibraryEvent = Event()
+
+
+ /**
+ * @return true if the str has launched an app task (be it successful or not)
+ * @param isWebview does not handle providers and opening download page if true. Can still add repos and login.
+ * */
+ fun handleAppIntentUrl(
+ activity: FragmentActivity?,
+ str: String?,
+ isWebview: Boolean
+ ): Boolean =
+ with(activity) {
+ // TODO MUCH BETTER HANDLING
+
+ // Invalid URIs can crash
+ fun safeURI(uri: String) = normalSafeApiCall { URI(uri) }
+
+ if (str != null && this != null) {
+ if (str.startsWith("https://cs.repo")) {
+ val realUrl = "https://" + str.substringAfter("?")
+ println("Repository url: $realUrl")
+ loadRepository(realUrl)
+ return true
+ } else if (str.contains(APP_STRING)) {
+ for (api in OAuth2Apis) {
+ if (str.contains("/${api.redirectUrl}")) {
+ ioSafe {
+ Log.i(TAG, "handleAppIntent $str")
+ val isSuccessful = api.handleRedirect(str)
+
+ if (isSuccessful) {
+ Log.i(TAG, "authenticated ${api.name}")
+ } else {
+ Log.i(TAG, "failed to authenticate ${api.name}")
+ }
+
+ this@with.runOnUiThread {
+ try {
+ showToast(
+ getString(if (isSuccessful) R.string.authenticated_user else R.string.authenticated_user_fail).format(
+ api.name
+ )
+ )
+ } catch (e: Exception) {
+ logError(e) // format might fail
+ }
+ }
+ }
+ return true
+ }
+ }
+ // This specific intent is used for the gradle deployWithAdb
+ // https://github.com/recloudstream/gradle/blob/master/src/main/kotlin/com/lagradost/cloudstream3/gradle/tasks/DeployWithAdbTask.kt#L46
+ if (str == "$APP_STRING:") {
+ PluginManager.hotReloadAllLocalPlugins(activity)
+ }
+ } else if (safeURI(str)?.scheme == APP_STRING_REPO) {
+ val url = str.replaceFirst(APP_STRING_REPO, "https")
+ loadRepository(url)
+ return true
+ } else if (safeURI(str)?.scheme == APP_STRING_SEARCH) {
+ val query = str.substringAfter("$APP_STRING_SEARCH://")
+ nextSearchQuery =
+ try {
+ URLDecoder.decode(query, "UTF-8")
+ } catch (t: Throwable) {
+ logError(t)
+ query
+ }
+ // Use both navigation views to support both layouts.
+ // It might be better to use the QuickSearch.
+ activity?.findViewById(R.id.nav_view)?.selectedItemId =
+ R.id.navigation_search
+ activity?.findViewById(R.id.nav_rail_view)?.selectedItemId =
+ R.id.navigation_search
+ } else if (safeURI(str)?.scheme == APP_STRING_PLAYER) {
+ val uri = Uri.parse(str)
+ val name = uri.getQueryParameter("name")
+ val url = URLDecoder.decode(uri.authority, "UTF-8")
+
+ navigate(
+ R.id.global_to_navigation_player,
+ GeneratorPlayer.newInstance(
+ LinkGenerator(
+ listOf(BasicLink(url, name)),
+ extract = true,
+ )
+ )
+ )
+ } else if (safeURI(str)?.scheme == APP_STRING_RESUME_WATCHING) {
+ val id =
+ str.substringAfter("$APP_STRING_RESUME_WATCHING://").toIntOrNull()
+ ?: return false
+ ioSafe {
+ val resumeWatchingCard =
+ HomeViewModel.getResumeWatching()?.firstOrNull { it.id == id }
+ ?: return@ioSafe
+ activity.loadSearchResult(
+ resumeWatchingCard,
+ START_ACTION_RESUME_LATEST
+ )
+ }
+ } else if (!isWebview) {
+ if (str.startsWith(DOWNLOAD_NAVIGATE_TO)) {
+ this.navigate(R.id.navigation_downloads)
+ return true
+ } else {
+ synchronized(apis) {
+ for (api in apis) {
+ if (str.startsWith(api.mainUrl)) {
+ loadResult(str, api.name)
+ return true
+ }
+ }
+ }
+ }
+ }
+ }
+ return false
+ }
+ }
+
+ var lastPopup: SearchResponse? = null
+ fun loadPopup(result: SearchResponse, load: Boolean = true) {
+ lastPopup = result
+ val syncName = syncViewModel.syncName(result.apiName)
+
+ // based on apiName we decide on if it is a local list or not, this is because
+ // we want to show a bit of extra UI to sync apis
+ if (result is SyncAPI.LibraryItem && syncName != null) {
+ isLocalList = false
+ syncViewModel.setSync(syncName, result.syncId)
+ syncViewModel.updateMetaAndUser()
+ } else {
+ isLocalList = true
+ syncViewModel.clear()
+ }
+
+ if (load) {
+ viewModel.load(
+ this, result.url, result.apiName, false, if (getApiDubstatusSettings()
+ .contains(DubStatus.Dubbed)
+ ) DubStatus.Dubbed else DubStatus.Subbed, null
+ )
+ } else {
+ viewModel.loadSmall(result)
+ }
}
override fun onColorSelected(dialogId: Int, color: Int) {
@@ -148,6 +488,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
updateLocale() // android fucks me by chaining lang when rotating the phone
+ updateTheme(this) // Update if system theme
val navHostFragment =
supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
@@ -158,7 +499,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
this.hideKeyboard()
// Fucks up anime info layout since that has its own layout
- cast_mini_controller_holder?.isVisible =
+ binding?.castMiniControllerHolder?.isVisible =
!listOf(
R.id.navigation_results_phone,
R.id.navigation_results_tv,
@@ -168,6 +509,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
val isNavVisible = listOf(
R.id.navigation_home,
R.id.navigation_search,
+ R.id.navigation_library,
R.id.navigation_downloads,
R.id.navigation_settings,
R.id.navigation_download_child,
@@ -177,30 +519,98 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
R.id.navigation_settings_updates,
R.id.navigation_settings_ui,
R.id.navigation_settings_account,
- R.id.navigation_settings_lang,
+ R.id.navigation_settings_providers,
R.id.navigation_settings_general,
R.id.navigation_settings_extensions,
R.id.navigation_settings_plugins,
+ R.id.navigation_test_providers,
).contains(destination.id)
+
+ val dontPush = listOf(
+ R.id.navigation_home,
+ R.id.navigation_search,
+ R.id.navigation_results_phone,
+ R.id.navigation_results_tv,
+ R.id.navigation_player,
+ R.id.navigation_quick_search,
+ ).contains(destination.id)
+
+ binding?.navHostFragment?.apply {
+ val params = layoutParams as ConstraintLayout.LayoutParams
+ val push =
+ if (!dontPush && isLayout(TV or EMULATOR)) resources.getDimensionPixelSize(R.dimen.navbar_width) else 0
+
+ if (!this.isLtr()) {
+ params.setMargins(
+ params.leftMargin,
+ params.topMargin,
+ push,
+ params.bottomMargin
+ )
+ } else {
+ params.setMargins(
+ push,
+ params.topMargin,
+ params.rightMargin,
+ params.bottomMargin
+ )
+ }
+
+ layoutParams = params
+ }
+
val landscape = when (resources.configuration.orientation) {
Configuration.ORIENTATION_LANDSCAPE -> {
true
}
+
Configuration.ORIENTATION_PORTRAIT -> {
- false
+ isLayout(TV or EMULATOR)
}
+
else -> {
false
}
}
- nav_view?.isVisible = isNavVisible && !landscape
- nav_rail_view?.isVisible = isNavVisible && landscape
+ binding?.apply {
+ navRailView.isVisible = isNavVisible && landscape
+ navView.isVisible = isNavVisible && !landscape
+
+ /**
+ * We need to make sure if we return to a sub-fragment,
+ * the correct navigation item is selected so that it does not
+ * highlight the wrong one in UI.
+ */
+ when (destination.id) {
+ in listOf(R.id.navigation_downloads, R.id.navigation_download_child) -> {
+ navRailView.menu.findItem(R.id.navigation_downloads).isChecked = true
+ navView.menu.findItem(R.id.navigation_downloads).isChecked = true
+ }
+ in listOf(
+ R.id.navigation_settings,
+ R.id.navigation_subtitles,
+ R.id.navigation_chrome_subtitles,
+ R.id.navigation_settings_player,
+ R.id.navigation_settings_updates,
+ R.id.navigation_settings_ui,
+ R.id.navigation_settings_account,
+ R.id.navigation_settings_providers,
+ R.id.navigation_settings_general,
+ R.id.navigation_settings_extensions,
+ R.id.navigation_settings_plugins,
+ R.id.navigation_test_providers
+ ) -> {
+ navRailView.menu.findItem(R.id.navigation_settings).isChecked = true
+ navView.menu.findItem(R.id.navigation_settings).isChecked = true
+ }
+ }
+ }
}
//private var mCastSession: CastSession? = null
- lateinit var mSessionManager: SessionManager
+ var mSessionManager: SessionManager? = null
private val mSessionManagerListener: SessionManagerListener by lazy { SessionManagerListenerImpl() }
private inner class SessionManagerListenerImpl : SessionManagerListener {
@@ -236,10 +646,11 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
override fun onResume() {
super.onResume()
+ afterPluginsLoadedEvent += ::onAllPluginsLoaded
+ setActivityInstance(this)
try {
if (isCastApiAvailable()) {
- //mCastSession = mSessionManager.currentCastSession
- mSessionManager.addSessionManagerListener(mSessionManagerListener)
+ mSessionManager?.addSessionManagerListener(mSessionManagerListener)
}
} catch (e: Exception) {
logError(e)
@@ -248,9 +659,14 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
override fun onPause() {
super.onPause()
+
+ // Start any delayed updates
+ if (ApkInstaller.delayedInstaller?.startInstallation() == true) {
+ Toast.makeText(this, R.string.update_started, Toast.LENGTH_LONG).show()
+ }
try {
if (isCastApiAvailable()) {
- mSessionManager.removeSessionManagerListener(mSessionManagerListener)
+ mSessionManager?.removeSessionManagerListener(mSessionManagerListener)
//mCastSession = null
}
} catch (e: Exception) {
@@ -258,11 +674,10 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
}
}
-
- override fun dispatchKeyEvent(event: KeyEvent?): Boolean {
- CommonActivity.dispatchKeyEvent(this, event)?.let {
- return it
- }
+ override fun dispatchKeyEvent(event: KeyEvent): Boolean {
+ val response = CommonActivity.dispatchKeyEvent(this, event)
+ if (response != null)
+ return response
return super.dispatchKeyEvent(event)
}
@@ -278,53 +693,32 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
onUserLeaveHint(this)
}
- private fun backPressed() {
- this.window?.navigationBarColor =
- this.colorFromAttribute(R.attr.primaryGrayBackground)
- this.updateLocale()
- super.onBackPressed()
- this.updateLocale()
- }
-
- override fun onBackPressed() {
- ((supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as? NavHostFragment?)?.childFragmentManager?.primaryNavigationFragment as? IOnBackPressed)?.onBackPressed()
- ?.let { runNormal ->
- if (runNormal) backPressed()
- } ?: run {
- backPressed()
+ private fun showConfirmExitDialog() {
+ val builder: AlertDialog.Builder = AlertDialog.Builder(this)
+ builder.setTitle(R.string.confirm_exit_dialog)
+ builder.apply {
+ // Forceful exit since back button can actually go back to setup
+ setPositiveButton(R.string.yes) { _, _ -> exitProcess(0) }
+ setNegativeButton(R.string.no) { _, _ -> }
}
- }
-
- override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
- if (requestCode == VLC_REQUEST_CODE) {
- if (resultCode == RESULT_OK && data != null) {
- val pos: Long =
- data.getLongExtra(
- VLC_EXTRA_POSITION_OUT,
- -1
- ) //Last position in media when player exited
- val dur: Long =
- data.getLongExtra(
- VLC_EXTRA_DURATION_OUT,
- -1
- ) //Last position in media when player exited
- val id = getKey(VLC_LAST_ID_KEY)
- println("SET KEY $id at $pos / $dur")
- if (dur > 0 && pos > 0) {
- setViewPos(id, pos, dur)
- }
- removeKey(VLC_LAST_ID_KEY)
- ResultFragment.updateUI()
- }
- }
- super.onActivityResult(requestCode, resultCode, data)
+ builder.show().setDefaultFocus()
}
override fun onDestroy() {
+ filesToDelete.forEach { path ->
+ val result = File(path).deleteRecursively()
+ if (result) {
+ Log.d(TAG, "Deleted temporary file: $path")
+ } else {
+ Log.d(TAG, "Failed to delete temporary file: $path")
+ }
+ }
+ filesToDelete = setOf()
val broadcastIntent = Intent()
broadcastIntent.action = "restart_service"
broadcastIntent.setClass(this, VideoDownloadRestartReceiver::class.java)
this.sendBroadcast(broadcastIntent)
+ afterPluginsLoadedEvent -= ::onAllPluginsLoaded
super.onDestroy()
}
@@ -337,56 +731,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
if (intent == null) return
val str = intent.dataString
loadCache()
- if (str != null) {
- if (str.startsWith("https://cs.repo")) {
- val realUrl = "https://" + str.substringAfter("?")
- println("Repository url: $realUrl")
- loadRepository(realUrl)
- } else if (str.contains(appString)) {
- for (api in OAuth2Apis) {
- if (str.contains("/${api.redirectUrl}")) {
- val activity = this
- ioSafe {
- Log.i(TAG, "handleAppIntent $str")
- val isSuccessful = api.handleRedirect(str)
-
- if (isSuccessful) {
- Log.i(TAG, "authenticated ${api.name}")
- } else {
- Log.i(TAG, "failed to authenticate ${api.name}")
- }
-
- activity.runOnUiThread {
- try {
- showToast(
- activity,
- getString(if (isSuccessful) R.string.authenticated_user else R.string.authenticated_user_fail).format(
- api.name
- )
- )
- } catch (e: Exception) {
- logError(e) // format might fail
- }
- }
- }
- }
- }
- } else if (URI(str).scheme == appStringRepo) {
- val url = str.replaceFirst(appStringRepo, "https")
- loadRepository(url)
- } else {
- if (str.startsWith(DOWNLOAD_NAVIGATE_TO)) {
- this.navigate(R.id.navigation_downloads)
- } else {
- for (api in apis) {
- if (str.startsWith(api.mainUrl)) {
- loadResult(str, api.name)
- break
- }
- }
- }
- }
- }
+ handleAppIntentUrl(this, str, false)
}
private fun NavDestination.matchDestination(@IdRes destId: Int): Boolean =
@@ -414,68 +759,772 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
}
}
+ private val pluginsLock = Mutex()
+ private fun onAllPluginsLoaded(success: Boolean = false) {
+ ioSafe {
+ pluginsLock.withLock {
+ synchronized(allProviders) {
+ // Load cloned sites after plugins have been loaded since clones depend on plugins.
+ try {
+ getKey>(USER_PROVIDER_API)?.let { list ->
+ list.forEach { custom ->
+ allProviders.firstOrNull { it.javaClass.simpleName == custom.parentJavaClass }
+ ?.let {
+ allProviders.add(it.javaClass.getDeclaredConstructor().newInstance().apply {
+ name = custom.name
+ lang = custom.lang
+ mainUrl = custom.url.trimEnd('/')
+ canBeOverridden = false
+ })
+ }
+ }
+ }
+ // it.hashCode() is not enough to make sure they are distinct
+ apis =
+ allProviders.distinctBy { it.lang + it.name + it.mainUrl + it.javaClass.name }
+ APIHolder.apiMap = null
+ } catch (e: Exception) {
+ logError(e)
+ }
+ }
+ }
+ }
+ }
+
+ lateinit var viewModel: ResultViewModel2
+ lateinit var syncViewModel: SyncViewModel
+ private var libraryViewModel: LibraryViewModel? = null
+
+ /** kinda dirty, however it signals that we should use the watch status as sync or not*/
+ var isLocalList: Boolean = false
+ override fun onCreateView(name: String, context: Context, attrs: AttributeSet): View? {
+
+ viewModel = ViewModelProvider(this)[ResultViewModel2::class.java]
+ syncViewModel = ViewModelProvider(this)[SyncViewModel::class.java]
+
+ return super.onCreateView(name, context, attrs)
+ }
+
+ private fun hidePreviewPopupDialog() {
+ bottomPreviewPopup.dismissSafe(this)
+ bottomPreviewPopup = null
+ bottomPreviewBinding = null
+ }
+
+ private var bottomPreviewPopup: BottomSheetDialog? = null
+ private var bottomPreviewBinding: BottomResultviewPreviewBinding? = null
+ private fun showPreviewPopupDialog(): BottomResultviewPreviewBinding {
+ val ret = (bottomPreviewBinding ?: run {
+ val builder =
+ BottomSheetDialog(this)
+ val binding: BottomResultviewPreviewBinding =
+ BottomResultviewPreviewBinding.inflate(builder.layoutInflater, null, false)
+ bottomPreviewBinding = binding
+ builder.setContentView(binding.root)
+ builder.setOnDismissListener {
+ bottomPreviewPopup = null
+ bottomPreviewBinding = null
+ viewModel.clear()
+ }
+ builder.setCanceledOnTouchOutside(true)
+ builder.show()
+ bottomPreviewPopup = builder
+ binding
+ })
+
+ return ret
+ }
+
+ var binding: ActivityMainBinding? = null
+
+ object TvFocus {
+ data class FocusTarget(
+ val width: Int,
+ val height: Int,
+ val x: Float,
+ val y: Float,
+ ) {
+ companion object {
+ fun lerp(a: FocusTarget, b: FocusTarget, lerp: Float): FocusTarget {
+ val ilerp = 1 - lerp
+ return FocusTarget(
+ width = (a.width * ilerp + b.width * lerp).toInt(),
+ height = (a.height * ilerp + b.height * lerp).toInt(),
+ x = a.x * ilerp + b.x * lerp,
+ y = a.y * ilerp + b.y * lerp
+ )
+ }
+ }
+ }
+
+ var last: FocusTarget = FocusTarget(0, 0, 0.0f, 0.0f)
+ var current: FocusTarget = FocusTarget(0, 0, 0.0f, 0.0f)
+
+ var focusOutline: WeakReference = WeakReference(null)
+ var lastFocus: WeakReference = WeakReference(null)
+ private val layoutListener: View.OnLayoutChangeListener =
+ View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
+ // shitty fix for layouts
+ lastFocus.get()?.apply {
+ updateFocusView(
+ this, same = true
+ )
+ postDelayed({
+ updateFocusView(
+ lastFocus.get(), same = false
+ )
+ }, 300)
+ }
+ }
+ private val attachListener: View.OnAttachStateChangeListener =
+ object : View.OnAttachStateChangeListener {
+ override fun onViewAttachedToWindow(v: View) {
+ updateFocusView(v)
+ }
+
+ override fun onViewDetachedFromWindow(v: View) {
+ // removes the focus view but not the listener as updateFocusView(null) will remove the listener
+ focusOutline.get()?.isVisible = false
+ }
+ }
+ /*private val scrollListener = object : RecyclerView.OnScrollListener() {
+ override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
+ super.onScrolled(recyclerView, dx, dy)
+ current = current.copy(x = current.x + dx, y = current.y + dy)
+ setTargetPosition(current)
+ }
+ }*/
+
+ private fun setTargetPosition(target: FocusTarget) {
+ focusOutline.get()?.apply {
+ layoutParams = layoutParams?.apply {
+ width = target.width
+ height = target.height
+ }
+
+ translationX = target.x
+ translationY = target.y
+ bringToFront()
+ }
+ }
+
+ private var animator: ValueAnimator? = null
+
+ /** if this is enabled it will keep the focus unmoving
+ * during listview move */
+ private const val NO_MOVE_LIST: Boolean = false
+
+ /** If this is enabled then it will try to move the
+ * listview focus to the left instead of center */
+ private const val LEFTMOST_MOVE_LIST: Boolean = true
+
+ private val reflectedScroll by lazy {
+ try {
+ RecyclerView::class.java.declaredMethods.firstOrNull {
+ it.name == "scrollStep"
+ }?.also { it.isAccessible = true }
+ } catch (t: Throwable) {
+ null
+ }
+ }
+
+ @MainThread
+ fun updateFocusView(newFocus: View?, same: Boolean = false) {
+ val focusOutline = focusOutline.get() ?: return
+ val lastView = lastFocus.get()
+ val exactlyTheSame = lastView == newFocus && newFocus != null
+ if (!exactlyTheSame) {
+ lastView?.removeOnLayoutChangeListener(layoutListener)
+ lastView?.removeOnAttachStateChangeListener(attachListener)
+ (lastView?.parent as? RecyclerView)?.apply {
+ removeOnLayoutChangeListener(layoutListener)
+ //removeOnScrollListener(scrollListener)
+ }
+ }
+
+ val wasGone = focusOutline.isGone
+
+ val visible =
+ newFocus != null && newFocus.measuredHeight > 0 && newFocus.measuredWidth > 0 && newFocus.isShown && newFocus.tag != "tv_no_focus_tag"
+ focusOutline.isVisible = visible
+
+ if (newFocus != null) {
+ lastFocus = WeakReference(newFocus)
+ val parent = newFocus.parent
+ var targetDx = 0
+ if (parent is RecyclerView) {
+ val layoutManager = parent.layoutManager
+ if (layoutManager is LinearListLayout && layoutManager.orientation == LinearLayoutManager.HORIZONTAL) {
+ val dx =
+ LinearSnapHelper().calculateDistanceToFinalSnap(layoutManager, newFocus)
+ ?.get(0)
+
+ if (dx != null) {
+ val rdx = if (LEFTMOST_MOVE_LIST) {
+ // this makes the item the leftmost in ltr, instead of center
+ val diff =
+ ((layoutManager.width - layoutManager.paddingStart - newFocus.measuredWidth) / 2) - newFocus.marginStart
+ dx + if (parent.isRtl()) {
+ -diff
+ } else {
+ diff
+ }
+ } else {
+ if (dx > 0) dx else 0
+ }
+
+ if (!NO_MOVE_LIST) {
+ parent.smoothScrollBy(rdx, 0)
+ } else {
+ val smoothScroll = reflectedScroll
+ if (smoothScroll == null) {
+ parent.smoothScrollBy(rdx, 0)
+ } else {
+ try {
+ // this is very fucked but because it is a protected method to
+ // be able to compute the scroll I use reflection, scroll, then
+ // scroll back, then smooth scroll and set the no move
+ val out = IntArray(2)
+ smoothScroll.invoke(parent, rdx, 0, out)
+ val scrolledX = out[0]
+ if (abs(scrolledX) <= 0) { // newFocus.measuredWidth*2
+ smoothScroll.invoke(parent, -rdx, 0, out)
+ parent.smoothScrollBy(scrolledX, 0)
+ if (NO_MOVE_LIST) targetDx = scrolledX
+ }
+ } catch (t: Throwable) {
+ parent.smoothScrollBy(rdx, 0)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ val out = IntArray(2)
+ newFocus.getLocationInWindow(out)
+ val (screenX, screenY) = out
+ var (x, y) = screenX.toFloat() to screenY.toFloat()
+ val (currentX, currentY) = focusOutline.translationX to focusOutline.translationY
+
+ if (!newFocus.isLtr()) {
+ x = x - focusOutline.rootView.width + newFocus.measuredWidth
+ }
+ x -= targetDx
+
+ // out of bounds = 0,0
+ if (screenX == 0 && screenY == 0) {
+ focusOutline.isVisible = false
+ }
+ if (!exactlyTheSame) {
+ (newFocus.parent as? RecyclerView)?.apply {
+ addOnLayoutChangeListener(layoutListener)
+ //addOnScrollListener(scrollListener)
+ }
+ newFocus.addOnLayoutChangeListener(layoutListener)
+ newFocus.addOnAttachStateChangeListener(attachListener)
+ }
+ val start = FocusTarget(
+ x = currentX,
+ y = currentY,
+ width = focusOutline.measuredWidth,
+ height = focusOutline.measuredHeight
+ )
+ val end = FocusTarget(
+ x = x,
+ y = y,
+ width = newFocus.measuredWidth,
+ height = newFocus.measuredHeight
+ )
+
+ // if they are the same within then snap, aka scrolling
+ val deltaMinX = min(end.width / 2, 60.toPx)
+ val deltaMinY = min(end.height / 2, 60.toPx)
+ if (start.width == end.width && start.height == end.height && (start.x - end.x).absoluteValue < deltaMinX && (start.y - end.y).absoluteValue < deltaMinY) {
+ animator?.cancel()
+ last = start
+ current = end
+ setTargetPosition(end)
+ return
+ }
+
+ // if running then "reuse"
+ if (animator?.isRunning == true) {
+ current = end
+ return
+ } else {
+ animator?.cancel()
+ }
+
+
+ last = start
+ current = end
+
+ // if previously gone, then tp
+ if (wasGone) {
+ setTargetPosition(current)
+ return
+ }
+
+ // animate between a and b
+ animator = ValueAnimator.ofFloat(0.0f, 1.0f).apply {
+ startDelay = 0
+ duration = 200
+ addUpdateListener { animation ->
+ val animatedValue = animation.animatedValue as Float
+ val target = FocusTarget.lerp(last, current, minOf(animatedValue, 1.0f))
+ setTargetPosition(target)
+ }
+ start()
+ }
+
+ // post check
+ if (!same) {
+ newFocus.postDelayed({
+ updateFocusView(lastFocus.get(), same = true)
+ }, 200)
+ }
+
+ /*
+
+ the following is working, but somewhat bad code code
+
+ if (!wasGone) {
+ (focusOutline.parent as? ViewGroup)?.let {
+ TransitionManager.endTransitions(it)
+ TransitionManager.beginDelayedTransition(
+ it,
+ TransitionSet().addTransition(ChangeBounds())
+ .addTransition(ChangeTransform())
+ .setDuration(100)
+ )
+ }
+ }
+
+ focusOutline.layoutParams = focusOutline.layoutParams?.apply {
+ width = newFocus.measuredWidth
+ height = newFocus.measuredHeight
+ }
+ focusOutline.translationX = x.toFloat()
+ focusOutline.translationY = y.toFloat()*/
+ }
+ }
+ }
+
+ private fun centerView(view: View?) {
+ if (view == null) return
+ try {
+ Log.v(TAG, "centerView: $view")
+ val r = Rect(0, 0, 0, 0)
+ view.getDrawingRect(r)
+ val x = r.centerX()
+ val y = r.centerY()
+ val dx = r.width() / 2 //screenWidth / 2
+ val dy = screenHeight / 2
+ val r2 = Rect(x - dx, y - dy, x + dx, y + dy)
+ view.requestRectangleOnScreen(r2, false)
+ // TvFocus.current =TvFocus.current.copy(y=y.toFloat())
+ } catch (_: Throwable) {
+ }
+ }
+
override fun onCreate(savedInstanceState: Bundle?) {
app.initClient(this)
val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
+ val errorFile = filesDir.resolve("last_error")
+ if (errorFile.exists() && errorFile.isFile) {
+ lastError = errorFile.readText(Charset.defaultCharset())
+ errorFile.delete()
+ } else {
+ lastError = null
+ }
+
+ val settingsForProvider = SettingsJson()
+ settingsForProvider.enableAdult =
+ settingsManager.getBoolean(getString(R.string.enable_nsfw_on_providers_key), false)
+
+ MainAPI.settingsForProvider = settingsForProvider
+
loadThemes(this)
updateLocale()
super.onCreate(savedInstanceState)
try {
if (isCastApiAvailable()) {
- mSessionManager = CastContext.getSharedInstance(this).sessionManager
+ CastContext.getSharedInstance(this) {it.run()}.addOnSuccessListener { mSessionManager = it.sessionManager }
}
- } catch (e: Exception) {
- logError(e)
+ } catch (t: Throwable) {
+ logError(t)
}
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN)
+ updateTv()
- if (isTvSettings()) {
- setContentView(R.layout.activity_main_tv)
- } else {
- setContentView(R.layout.activity_main)
+ // backup when we update the app, I don't trust myself to not boot lock users, might want to make this a setting?
+ normalSafeApiCall {
+ val appVer = BuildConfig.VERSION_NAME
+ val lastAppAutoBackup: String = getKey("VERSION_NAME") ?: ""
+ if (appVer != lastAppAutoBackup) {
+ setKey("VERSION_NAME", BuildConfig.VERSION_NAME)
+ normalSafeApiCall {
+ backup(this)
+ }
+ normalSafeApiCall {
+ // Recompile oat on new version
+ PluginManager.deleteAllOatFiles(this)
+ }
+ }
}
- changeStatusBarState(isEmulatorSettings())
+ // just in case, MAIN SHOULD *NEVER* BOOT LOOP CRASH
+ binding = try {
+ if (isLayout(TV or EMULATOR)) {
+ val newLocalBinding = ActivityMainTvBinding.inflate(layoutInflater, null, false)
+ setContentView(newLocalBinding.root)
- ioSafe {
- getKey(USER_SELECTED_HOMEPAGE_API)?.let { homeApi ->
- mainPluginsLoadedEvent.invoke(loadSinglePlugin(this@MainActivity, homeApi))
- } ?: run {
- mainPluginsLoadedEvent.invoke(false)
- }
+ if (isLayout(TV) && ANIMATED_OUTLINE) {
+ TvFocus.focusOutline = WeakReference(newLocalBinding.focusOutline)
+ newLocalBinding.root.viewTreeObserver.addOnScrollChangedListener {
+ TvFocus.updateFocusView(TvFocus.lastFocus.get(), same = true)
+ }
+ newLocalBinding.root.viewTreeObserver.addOnGlobalFocusChangeListener { _, newFocus ->
+ TvFocus.updateFocusView(newFocus)
+ }
+ } else {
+ newLocalBinding.focusOutline.isVisible = false
+ }
- if (settingsManager.getBoolean(getString(R.string.auto_update_plugins_key), true)) {
- PluginManager.updateAllOnlinePluginsAndLoadThem(this@MainActivity)
- } else {
- PluginManager.loadAllOnlinePlugins(this@MainActivity)
- }
+ if (isLayout(TV)) {
+ // Put here any button you don't want focusing it to center the view
+ val exceptionButtons = listOf(
+ R.id.home_preview_play_btt,
+ R.id.home_preview_info_btt,
+ R.id.home_preview_hidden_next_focus,
+ R.id.home_preview_hidden_prev_focus,
+ R.id.result_play_movie_button,
+ R.id.result_play_series_button,
+ R.id.result_resume_series_button,
+ R.id.result_play_trailer_button,
+ R.id.result_bookmark_Button,
+ R.id.result_favorite_Button,
+ R.id.result_subscribe_Button,
+ R.id.result_search_Button,
+ R.id.result_episodes_show_button,
+ )
- PluginManager.loadAllLocalPlugins(this@MainActivity)
-
- // Load cloned sites after plugins have been loaded since clones depend on plugins.
- try {
- getKey>(USER_PROVIDER_API)?.let { list ->
- list.forEach { custom ->
- allProviders.firstOrNull { it.javaClass.simpleName == custom.parentJavaClass }
- ?.let {
- allProviders.add(it.javaClass.newInstance().apply {
- name = custom.name
- lang = custom.lang
- mainUrl = custom.url.trimEnd('/')
- canBeOverridden = false
- })
- }
+ newLocalBinding.root.viewTreeObserver.addOnGlobalFocusChangeListener { _, newFocus ->
+ if (exceptionButtons.contains(newFocus?.id)) return@addOnGlobalFocusChangeListener
+ centerView(newFocus)
}
}
- apis = allProviders.distinctBy { it }
- APIHolder.apiMap = null
- } catch (e: Exception) {
- logError(e)
- }
- afterPluginsLoadedEvent.invoke(true)
+ ActivityMainBinding.bind(newLocalBinding.root) // this may crash
+ } else {
+ val newLocalBinding = ActivityMainBinding.inflate(layoutInflater, null, false)
+ setContentView(newLocalBinding.root)
+ newLocalBinding
+ }
+ } catch (t: Throwable) {
+ showToast(txt(R.string.unable_to_inflate, t.message ?: ""), Toast.LENGTH_LONG)
+ null
+ }
+
+ changeStatusBarState(isLayout(EMULATOR))
+
+ /** Biometric stuff for users without accounts **/
+ val noAccounts = settingsManager.getBoolean(
+ getString(R.string.skip_startup_account_select_key),
+ false
+ ) || accounts.count() <= 1
+
+ if (isLayout(PHONE) && isAuthEnabled(this) && noAccounts) {
+ if (deviceHasPasswordPinLock(this)) {
+ startBiometricAuthentication(this, R.string.biometric_authentication_title, false)
+
+ promptInfo?.let { prompt ->
+ biometricPrompt?.authenticate(prompt)
+ }
+
+ // hide background while authenticating, Sorry moms & dads 🙏
+ binding?.navHostFragment?.isInvisible = true
+ }
+ }
+
+ // Automatically enable jsdelivr if cant connect to raw.githubusercontent.com
+ if (this.getKey(getString(R.string.jsdelivr_proxy_key)) == null && isNetworkAvailable()) {
+ main {
+ if (checkGithubConnectivity()) {
+ this.setKey(getString(R.string.jsdelivr_proxy_key), false)
+ } else {
+ this.setKey(getString(R.string.jsdelivr_proxy_key), true)
+ showSnackbar(
+ this@MainActivity,
+ R.string.jsdelivr_enabled,
+ Snackbar.LENGTH_LONG,
+ R.string.revert
+ ) { setKey(getString(R.string.jsdelivr_proxy_key), false) }
+ }
+ }
+ }
+
+ ioSafe { SafeFile.check(this@MainActivity) }
+
+ if (PluginManager.checkSafeModeFile()) {
+ normalSafeApiCall {
+ showToast(R.string.safe_mode_file, Toast.LENGTH_LONG)
+ }
+ } else if (lastError == null) {
+ ioSafe {
+ DataStoreHelper.currentHomePage?.let { homeApi ->
+ mainPluginsLoadedEvent.invoke(loadSinglePlugin(this@MainActivity, homeApi))
+ } ?: run {
+ mainPluginsLoadedEvent.invoke(false)
+ }
+
+ ioSafe {
+ if (settingsManager.getBoolean(
+ getString(R.string.auto_update_plugins_key),
+ true
+ )
+ ) {
+ PluginManager.updateAllOnlinePluginsAndLoadThem(this@MainActivity)
+ } else {
+ loadAllOnlinePlugins(this@MainActivity)
+ }
+
+ //Automatically download not existing plugins, using mode specified.
+ val autoDownloadPlugin = AutoDownloadMode.getEnum(
+ settingsManager.getInt(
+ getString(R.string.auto_download_plugins_key),
+ 0
+ )
+ ) ?: AutoDownloadMode.Disable
+ if (autoDownloadPlugin != AutoDownloadMode.Disable) {
+ PluginManager.downloadNotExistingPluginsAndLoad(
+ this@MainActivity,
+ autoDownloadPlugin
+ )
+ }
+ }
+
+ ioSafe {
+ PluginManager.loadAllLocalPlugins(this@MainActivity, false)
+ }
+ }
+ } else {
+ val builder: AlertDialog.Builder = AlertDialog.Builder(this)
+ builder.setTitle(R.string.safe_mode_title)
+ builder.setMessage(R.string.safe_mode_description)
+ builder.apply {
+ setPositiveButton(R.string.safe_mode_crash_info) { _, _ ->
+ val tbBuilder: AlertDialog.Builder = AlertDialog.Builder(context)
+ tbBuilder.setTitle(R.string.safe_mode_title)
+ tbBuilder.setMessage(lastError)
+ tbBuilder.show()
+ }
+
+ setNegativeButton("Ok") { _, _ -> }
+ }
+ builder.show().setDefaultFocus()
+ }
+
+
+ fun setUserData(status: Resource?) {
+ if (isLocalList) return
+ bottomPreviewBinding?.apply {
+ when (status) {
+ is Resource.Success -> {
+ resultviewPreviewBookmark.isEnabled = true
+ resultviewPreviewBookmark.setText(status.value.status.stringRes)
+ resultviewPreviewBookmark.setIconResource(status.value.status.iconRes)
+ }
+
+ is Resource.Failure -> {
+ resultviewPreviewBookmark.isEnabled = false
+ resultviewPreviewBookmark.setIconResource(R.drawable.ic_baseline_bookmark_border_24)
+ resultviewPreviewBookmark.text = status.errorString
+ }
+
+ else -> {
+ resultviewPreviewBookmark.isEnabled = false
+ resultviewPreviewBookmark.setIconResource(R.drawable.ic_baseline_bookmark_border_24)
+ resultviewPreviewBookmark.setText(R.string.loading)
+ }
+ }
+ }
+ }
+
+ fun setWatchStatus(state: WatchType?) {
+ if (!isLocalList || state == null) return
+
+ bottomPreviewBinding?.resultviewPreviewBookmark?.apply {
+ setIconResource(state.iconRes)
+ setText(state.stringRes)
+ }
+ }
+
+ fun setSubscribeStatus(state: Boolean?) {
+ bottomPreviewBinding?.resultviewPreviewSubscribe?.apply {
+ if (state != null) {
+ val drawable = if (state) {
+ R.drawable.ic_baseline_notifications_active_24
+ } else {
+ R.drawable.baseline_notifications_none_24
+ }
+ setImageResource(drawable)
+ }
+ isVisible = state != null
+
+ setOnClickListener {
+ viewModel.toggleSubscriptionStatus(context) { newStatus: Boolean? ->
+ if (newStatus == null) return@toggleSubscriptionStatus
+
+ val message = if (newStatus) {
+ // Kinda icky to have this here, but it works.
+ SubscriptionWorkManager.enqueuePeriodicWork(context)
+ R.string.subscription_new
+ } else {
+ R.string.subscription_deleted
+ }
+
+ val name = (viewModel.page.value as? Resource.Success)?.value?.title
+ ?: txt(R.string.no_data).asStringNull(context) ?: ""
+ showToast(txt(message, name), Toast.LENGTH_SHORT)
+ }
+ }
+ }
+ }
+
+ observe(viewModel.watchStatus, ::setWatchStatus)
+ observe(syncViewModel.userData, ::setUserData)
+ observeNullable(viewModel.subscribeStatus, ::setSubscribeStatus)
+
+ observeNullable(viewModel.page) { resource ->
+ if (resource == null) {
+ hidePreviewPopupDialog()
+ return@observeNullable
+ }
+ when (resource) {
+ is Resource.Failure -> {
+ showToast(R.string.error)
+ viewModel.clear()
+ hidePreviewPopupDialog()
+ }
+
+ is Resource.Loading -> {
+ showPreviewPopupDialog().apply {
+ resultviewPreviewLoading.isVisible = true
+ resultviewPreviewResult.isVisible = false
+ resultviewPreviewLoadingShimmer.startShimmer()
+ }
+ }
+
+ is Resource.Success -> {
+ val d = resource.value
+ showPreviewPopupDialog().apply {
+ resultviewPreviewLoading.isVisible = false
+ resultviewPreviewResult.isVisible = true
+ resultviewPreviewLoadingShimmer.stopShimmer()
+
+ resultviewPreviewTitle.text = d.title
+
+ resultviewPreviewMetaType.setText(d.typeText)
+ resultviewPreviewMetaYear.setText(d.yearText)
+ resultviewPreviewMetaDuration.setText(d.durationText)
+ resultviewPreviewMetaRating.setText(d.ratingText)
+
+ resultviewPreviewDescription.setTextHtml(d.plotText)
+ resultviewPreviewPoster.setImage(
+ d.posterImage ?: d.posterBackgroundImage
+ )
+
+ setUserData(syncViewModel.userData.value)
+ setWatchStatus(viewModel.watchStatus.value)
+ setSubscribeStatus(viewModel.subscribeStatus.value)
+
+ resultviewPreviewBookmark.setOnClickListener {
+ //viewModel.updateWatchStatus(WatchType.PLANTOWATCH)
+ if (isLocalList) {
+ val value = viewModel.watchStatus.value ?: WatchType.NONE
+
+ this@MainActivity.showBottomDialog(
+ WatchType.entries.map { getString(it.stringRes) }.toList(),
+ value.ordinal,
+ this@MainActivity.getString(R.string.action_add_to_bookmarks),
+ showApply = false,
+ {}) {
+ viewModel.updateWatchStatus(
+ WatchType.entries[it],
+ this@MainActivity
+ )
+ }
+ } else {
+ val value =
+ (syncViewModel.userData.value as? Resource.Success)?.value?.status
+ ?: SyncWatchType.NONE
+
+ this@MainActivity.showBottomDialog(
+ SyncWatchType.entries.map { getString(it.stringRes) }.toList(),
+ value.ordinal,
+ this@MainActivity.getString(R.string.action_add_to_bookmarks),
+ showApply = false,
+ {}) {
+ syncViewModel.setStatus(SyncWatchType.entries[it].internalId)
+ syncViewModel.publishUserData()
+ }
+ }
+ }
+
+ observeNullable(viewModel.favoriteStatus) observeFavoriteStatus@{ isFavorite ->
+ resultviewPreviewFavorite.isVisible = isFavorite != null
+ if (isFavorite == null) return@observeFavoriteStatus
+
+ val drawable = if (isFavorite) {
+ R.drawable.ic_baseline_favorite_24
+ } else {
+ R.drawable.ic_baseline_favorite_border_24
+ }
+
+ resultviewPreviewFavorite.setImageResource(drawable)
+ }
+
+ resultviewPreviewFavorite.setOnClickListener {
+ viewModel.toggleFavoriteStatus(this@MainActivity) { newStatus: Boolean? ->
+ if (newStatus == null) return@toggleFavoriteStatus
+
+ val message = if (newStatus) {
+ R.string.favorite_added
+ } else {
+ R.string.favorite_removed
+ }
+
+ val name = (viewModel.page.value as? Resource.Success)?.value?.title
+ ?: txt(R.string.no_data).asStringNull(this@MainActivity) ?: ""
+ showToast(txt(message, name), Toast.LENGTH_SHORT)
+ }
+ }
+
+ if (isLayout(PHONE)) // dont want this clickable on tv layout
+ resultviewPreviewDescription.setOnClickListener { view ->
+ view.context?.let { ctx ->
+ val builder: AlertDialog.Builder =
+ AlertDialog.Builder(ctx, R.style.AlertDialogCustom)
+ builder.setMessage(d.plotText.asString(ctx).html())
+ .setTitle(d.plotHeaderText.asString(ctx))
+ .show()
+ }
+ }
+
+ resultviewPreviewMoreInfo.setOnClickListener {
+ viewModel.clear()
+ hidePreviewPopupDialog()
+ lastPopup?.let {
+ loadSearchResult(it)
+ }
+ }
+ }
+ }
+ }
}
// ioSafe {
@@ -493,16 +1542,34 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
for (api in accountManagers) {
api.init()
}
- }
- ioSafe {
- inAppAuths.apmap { api ->
+ inAppAuths.amap { api ->
try {
api.initialize()
} catch (e: Exception) {
logError(e)
}
}
+
+ // we need to run this after we init all apis, otherwise currentSyncApi will fuck itself
+ this@MainActivity.runOnUiThread {
+ // Change library icon with logo of current api in sync
+ libraryViewModel = ViewModelProvider(this@MainActivity)[LibraryViewModel::class.java]
+ libraryViewModel?.currentApiName?.observe(this@MainActivity) {
+ val syncAPI = libraryViewModel?.currentSyncApi
+ Log.i("SYNC_API", "${syncAPI?.name}, ${syncAPI?.idPrefix}")
+ val icon = if (syncAPI?.idPrefix == localListApi.idPrefix) {
+ R.drawable.library_icon
+ } else {
+ syncAPI?.icon ?: R.drawable.library_icon
+ }
+
+ binding?.apply {
+ navRailView.menu.findItem(R.id.navigation_library)?.setIcon(icon)
+ navView.menu.findItem(R.id.navigation_library)?.setIcon(icon)
+ }
+ }
+ }
}
SearchResultBuilder.updateCache(this)
@@ -510,7 +1577,9 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
ioSafe {
initAll()
// No duplicates (which can happen by registerMainAPI)
- apis = allProviders.distinctBy { it }
+ apis = synchronized(allProviders) {
+ allProviders.distinctBy { it }
+ }
}
// val navView: BottomNavigationView = findViewById(R.id.nav_view)
@@ -520,6 +1589,28 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
val navHostFragment =
supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
val navController = navHostFragment.navController
+
+ navController.addOnDestinationChangedListener { _: NavController, navDestination: NavDestination, bundle: Bundle? ->
+ // Intercept search and add a query
+ updateNavBar(navDestination)
+ if (navDestination.matchDestination(R.id.navigation_search) && !nextSearchQuery.isNullOrBlank()) {
+ bundle?.apply {
+ this.putString(SearchFragment.SEARCH_QUERY, nextSearchQuery)
+ }
+ }
+
+ if (isLayout(TV or EMULATOR)) {
+ if (navDestination.matchDestination(R.id.navigation_home)) {
+ attachBackPressedCallback {
+ showConfirmExitDialog()
+ window?.navigationBarColor =
+ colorFromAttribute(R.attr.primaryGrayBackground)
+ updateLocale()
+ }
+ } else detachBackPressedCallback()
+ }
+ }
+
//val navController = findNavController(R.id.nav_host_fragment)
/*navOptions = NavOptions.Builder()
@@ -530,24 +1621,47 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
.setPopExitAnim(R.anim.nav_pop_exit)
.setPopUpTo(navController.graph.startDestination, false)
.build()*/
- nav_view?.setupWithNavController(navController)
- val nav_rail = findViewById(R.id.nav_rail_view)
- nav_rail?.setupWithNavController(navController)
- nav_rail?.setOnItemSelectedListener { item ->
- onNavDestinationSelected(
- item,
- navController
- )
+ val rippleColor = ColorStateList.valueOf(getResourceColor(R.attr.colorPrimary, 0.1f))
+
+ binding?.navView?.apply {
+ itemRippleColor = rippleColor
+ itemActiveIndicatorColor = rippleColor
+ setupWithNavController(navController)
+ setOnItemSelectedListener { item ->
+ onNavDestinationSelected(
+ item,
+ navController
+ )
+ }
}
- nav_view?.setOnItemSelectedListener { item ->
- onNavDestinationSelected(
- item,
- navController
- )
- }
- navController.addOnDestinationChangedListener { _, destination, _ ->
- updateNavBar(destination)
+
+ binding?.navRailView?.apply {
+ itemRippleColor = rippleColor
+ itemActiveIndicatorColor = rippleColor
+ setupWithNavController(navController)
+ if (isLayout(TV or EMULATOR)) {
+ background?.alpha = 200
+ } else {
+ background?.alpha = 255
+ }
+
+ setOnItemSelectedListener { item ->
+ onNavDestinationSelected(
+ item,
+ navController
+ )
+ }
+
+ fun noFocus(view: View) {
+ view.tag = view.context.getString(R.string.tv_no_focus_tag)
+ (view as? ViewGroup)?.let {
+ for (child in it.children) {
+ noFocus(child)
+ }
+ }
+ }
+ noFocus(this)
}
loadCache()
@@ -570,17 +1684,12 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
true
}*/
- val rippleColor = ColorStateList.valueOf(getResourceColor(R.attr.colorPrimary, 0.1f))
- nav_view?.itemRippleColor = rippleColor
- nav_rail?.itemRippleColor = rippleColor
- nav_rail?.itemActiveIndicatorColor = rippleColor
- nav_view?.itemActiveIndicatorColor = rippleColor
if (!checkWrite()) {
requestRW()
if (checkWrite()) return
}
- CastButtonFactory.setUpMediaRouteButton(this, media_route_button)
+ //CastButtonFactory.setUpMediaRouteButton(this, media_route_button)
// THIS IS CURRENTLY REMOVED BECAUSE HIGHER VERS OF ANDROID NEEDS A NOTIFICATION
//if (!VideoDownloadManager.isMyServiceRunning(this, VideoDownloadKeepAliveService::class.java)) {
@@ -647,14 +1756,15 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
if (BuildConfig.DEBUG) {
var providersAndroidManifestString = "Current androidmanifest should be:\n"
- for (api in allProviders) {
- providersAndroidManifestString += "\n"
+ synchronized(allProviders) {
+ for (api in allProviders) {
+ providersAndroidManifestString += "\n"
+ }
}
-
println(providersAndroidManifestString)
}
@@ -664,13 +1774,15 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
runAutoUpdate()
}
+ FcastManager().init(this, false)
+
APIRepository.dubStatusActive = getApiDubstatusSettings()
try {
// this ensures that no unnecessary space is taken
loadCache()
File(filesDir, "exoplayer").deleteRecursively() // old cache
- File(cacheDir, "exoplayer").deleteOnExit() // current cache
+ deleteFileOnExit(File(cacheDir, "exoplayer")) // current cache
} catch (e: Exception) {
logError(e)
}
@@ -680,6 +1792,11 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
migrateResumeWatching()
}
+ getKey(USER_SELECTED_HOMEPAGE_API)?.let { homepage ->
+ DataStoreHelper.currentHomePage = homepage
+ removeKey(USER_SELECTED_HOMEPAGE_API)
+ }
+
try {
if (getKey(HAS_DONE_SETUP_KEY, false) != true) {
navController.navigate(R.id.navigation_setup_language)
@@ -695,17 +1812,53 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
}
} catch (e: Exception) {
logError(e)
- } finally {
- setKey(HAS_DONE_SETUP_KEY, true)
}
// Used to check current focus for TV
// main {
// while (true) {
-// delay(1000)
+// delay(5000)
// println("Current focus: $currentFocus")
+// showToast(this, currentFocus.toString(), Toast.LENGTH_LONG)
// }
// }
+ onBackPressedDispatcher.addCallback(
+ this,
+ object : OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() {
+ window?.navigationBarColor = colorFromAttribute(R.attr.primaryGrayBackground)
+ updateLocale()
+
+ // If we don't disable we end up in a loop with default behavior calling
+ // this callback as well, so we disable it, run default behavior,
+ // then re-enable this callback so it can be used for next back press.
+ isEnabled = false
+ onBackPressedDispatcher.onBackPressed()
+ isEnabled = true
+ }
+ }
+ )
}
-}
+
+ /** Biometric stuff **/
+ override fun onAuthenticationSuccess() {
+ // make background (nav host fragment) visible again
+ binding?.navHostFragment?.isInvisible = false
+ }
+
+ override fun onAuthenticationError() {
+ finish()
+ }
+
+ suspend fun checkGithubConnectivity(): Boolean {
+ return try {
+ app.get(
+ "https://raw.githubusercontent.com/recloudstream/.github/master/connectivitycheck",
+ timeout = 5
+ ).text.trim() == "ok"
+ } catch (t: Throwable) {
+ false
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Acefile.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Acefile.kt
deleted file mode 100644
index fe46791b..00000000
--- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Acefile.kt
+++ /dev/null
@@ -1,40 +0,0 @@
-package com.lagradost.cloudstream3.extractors
-
-import com.lagradost.cloudstream3.app
-import com.lagradost.cloudstream3.base64Decode
-import com.lagradost.cloudstream3.utils.*
-
-class Acefile : ExtractorApi() {
- override val name = "Acefile"
- override val mainUrl = "https://acefile.co"
- override val requiresReferer = false
-
- override suspend fun getUrl(url: String, referer: String?): List {
- val sources = mutableListOf()
- app.get(url).document.select("script").map { script ->
- if (script.data().contains("eval(function(p,a,c,k,e,d)")) {
- val data = getAndUnpack(script.data())
- val id = data.substringAfter("{\"id\":\"").substringBefore("\",")
- val key = data.substringAfter("var nfck=\"").substringBefore("\";")
- app.get("https://acefile.co/local/$id?key=$key").text.let {
- base64Decode(
- it.substringAfter("JSON.parse(atob(\"").substringBefore("\"))")
- ).let { res ->
- sources.add(
- ExtractorLink(
- name,
- name,
- res.substringAfter("\"file\":\"").substringBefore("\","),
- "$mainUrl/",
- Qualities.Unknown.value,
- headers = mapOf("range" to "bytes=0-")
- )
- )
- }
- }
- }
- }
- return sources
- }
-
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Fastream.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Fastream.kt
deleted file mode 100644
index 16b109be..00000000
--- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Fastream.kt
+++ /dev/null
@@ -1,39 +0,0 @@
-package com.lagradost.cloudstream3.extractors
-
-import com.lagradost.cloudstream3.apmap
-import com.lagradost.cloudstream3.app
-import com.lagradost.cloudstream3.utils.ExtractorApi
-import com.lagradost.cloudstream3.utils.ExtractorLink
-import com.lagradost.cloudstream3.utils.M3u8Helper.Companion.generateM3u8
-
-class Fastream: ExtractorApi() {
- override var mainUrl = "https://fastream.to"
- override var name = "Fastream"
- override val requiresReferer = false
-
-
- override suspend fun getUrl(url: String, referer: String?): List? {
- val id = Regex("emb\\.html\\?(.*)\\=(enc|)").find(url)?.destructured?.component1() ?: return emptyList()
- val sources = mutableListOf()
- val response = app.post("$mainUrl/dl",
- data = mapOf(
- Pair("op","embed"),
- Pair("file_code",id),
- Pair("auto","1")
- )).document
- response.select("script").apmap { script ->
- if (script.data().contains("sources")) {
- val m3u8regex = Regex("((https:|http:)\\/\\/.*\\.m3u8)")
- val m3u8 = m3u8regex.find(script.data())?.value ?: return@apmap
- generateM3u8(
- name,
- m3u8,
- mainUrl
- ).forEach { link ->
- sources.add(link)
- }
- }
- }
- return sources
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Filesim.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Filesim.kt
deleted file mode 100644
index 5c8af1c5..00000000
--- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Filesim.kt
+++ /dev/null
@@ -1,38 +0,0 @@
-package com.lagradost.cloudstream3.extractors
-
-import com.fasterxml.jackson.annotation.JsonProperty
-import com.lagradost.cloudstream3.app
-import com.lagradost.cloudstream3.utils.*
-import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
-
-class Filesim : ExtractorApi() {
- override val name = "Filesim"
- override val mainUrl = "https://files.im"
- override val requiresReferer = false
-
- override suspend fun getUrl(url: String, referer: String?): List {
- val sources = mutableListOf()
- with(app.get(url).document) {
- this.select("script").map { script ->
- if (script.data().contains("eval(function(p,a,c,k,e,d)")) {
- val data = getAndUnpack(script.data()).substringAfter("sources:[").substringBefore("]")
- tryParseJson>("[$data]")?.map {
- M3u8Helper.generateM3u8(
- name,
- it.file,
- "$mainUrl/",
- ).forEach { m3uData -> sources.add(m3uData) }
- }
- }
- }
- }
- return sources
- }
-
- private data class ResponseSource(
- @JsonProperty("file") val file: String,
- @JsonProperty("type") val type: String?,
- @JsonProperty("label") val label: String?
- )
-
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/GuardareStream.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/GuardareStream.kt
deleted file mode 100644
index 57435161..00000000
--- a/app/src/main/java/com/lagradost/cloudstream3/extractors/GuardareStream.kt
+++ /dev/null
@@ -1,36 +0,0 @@
-package com.lagradost.cloudstream3.extractors
-import com.fasterxml.jackson.annotation.JsonProperty
-import com.lagradost.cloudstream3.utils.ExtractorLink
-import com.lagradost.cloudstream3.app
-import com.lagradost.cloudstream3.utils.*
-
-open class GuardareStream : ExtractorApi() {
- override var name = "Guardare"
- override var mainUrl = "https://guardare.stream"
- override val requiresReferer = false
-
- data class GuardareJsonData (
- @JsonProperty("data") val data : List,
- )
-
- data class GuardareData (
- @JsonProperty("file") val file : String,
- @JsonProperty("label") val label : String,
- @JsonProperty("type") val type : String
- )
-
- override suspend fun getUrl(url: String, referer: String?): List? {
- val response = app.post(url.replace("/v/","/api/source/"), data = mapOf("d" to mainUrl)).text
- val jsonvideodata = AppUtils.parseJson(response)
- return jsonvideodata.data.map {
- ExtractorLink(
- it.file+".${it.type}",
- this.name,
- it.file+".${it.type}",
- mainUrl,
- it.label.filter{ it.isDigit() }.toInt(),
- false
- )
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Linkbox.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Linkbox.kt
deleted file mode 100644
index 52fc5532..00000000
--- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Linkbox.kt
+++ /dev/null
@@ -1,46 +0,0 @@
-package com.lagradost.cloudstream3.extractors
-
-import com.fasterxml.jackson.annotation.JsonProperty
-import com.lagradost.cloudstream3.app
-import com.lagradost.cloudstream3.utils.ExtractorApi
-import com.lagradost.cloudstream3.utils.ExtractorLink
-import com.lagradost.cloudstream3.utils.getQualityFromName
-
-class Linkbox : ExtractorApi() {
- override val name = "Linkbox"
- override val mainUrl = "https://www.linkbox.to"
- override val requiresReferer = true
-
- override suspend fun getUrl(url: String, referer: String?): List {
- val id = url.substringAfter("id=")
- val sources = mutableListOf()
-
- app.get("$mainUrl/api/open/get_url?itemId=$id", referer=url).parsedSafe()?.data?.rList?.map { link ->
- sources.add(
- ExtractorLink(
- name,
- name,
- link.url,
- url,
- getQualityFromName(link.resolution)
- )
- )
- }
-
- return sources
- }
-
- data class RList(
- @JsonProperty("url") val url: String,
- @JsonProperty("resolution") val resolution: String?,
- )
-
- data class Data(
- @JsonProperty("rList") val rList: List?,
- )
-
- data class Responses(
- @JsonProperty("data") val data: Data?,
- )
-
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Mcloud.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Mcloud.kt
deleted file mode 100644
index 29d98557..00000000
--- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Mcloud.kt
+++ /dev/null
@@ -1,7 +0,0 @@
-package com.lagradost.cloudstream3.extractors
-
-open class Mcloud : WcoStream() {
- override var name = "Mcloud"
- override var mainUrl = "https://mcloud.to"
- override val requiresReferer = true
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Mp4Upload.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Mp4Upload.kt
deleted file mode 100644
index 68a4a103..00000000
--- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Mp4Upload.kt
+++ /dev/null
@@ -1,34 +0,0 @@
-package com.lagradost.cloudstream3.extractors
-
-import com.lagradost.cloudstream3.app
-import com.lagradost.cloudstream3.utils.ExtractorApi
-import com.lagradost.cloudstream3.utils.ExtractorLink
-import com.lagradost.cloudstream3.utils.Qualities
-import com.lagradost.cloudstream3.utils.getAndUnpack
-
-class Mp4Upload : ExtractorApi() {
- override var name = "Mp4Upload"
- override var mainUrl = "https://www.mp4upload.com"
- private val srcRegex = Regex("""player\.src\("(.*?)"""")
- override val requiresReferer = true
-
- override suspend fun getUrl(url: String, referer: String?): List? {
- with(app.get(url)) {
- getAndUnpack(this.text).let { unpackedText ->
- val quality = unpackedText.lowercase().substringAfter(" height=").substringBefore(" ").toIntOrNull()
- srcRegex.find(unpackedText)?.groupValues?.get(1)?.let { link ->
- return listOf(
- ExtractorLink(
- name,
- name,
- link,
- url,
- quality ?: Qualities.Unknown.value,
- )
- )
- }
- }
- }
- return null
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/OkRuExtractor.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/OkRuExtractor.kt
deleted file mode 100644
index 70e87fbf..00000000
--- a/app/src/main/java/com/lagradost/cloudstream3/extractors/OkRuExtractor.kt
+++ /dev/null
@@ -1,67 +0,0 @@
-package com.lagradost.cloudstream3.extractors
-
-import com.fasterxml.jackson.annotation.JsonProperty
-import com.lagradost.cloudstream3.utils.*
-import com.lagradost.cloudstream3.app
-import com.lagradost.cloudstream3.utils.AppUtils.parseJson
-
-data class DataOptionsJson (
- @JsonProperty("flashvars") var flashvars : Flashvars? = Flashvars(),
-)
-data class Flashvars (
- @JsonProperty("metadata") var metadata : String? = null,
- @JsonProperty("hlsManifestUrl") var hlsManifestUrl : String? = null, //m3u8
-)
-
-data class MetadataOkru (
- @JsonProperty("videos") var videos: ArrayList = arrayListOf(),
-)
-
-data class Videos (
- @JsonProperty("name") var name : String,
- @JsonProperty("url") var url : String,
- @JsonProperty("seekSchema") var seekSchema : Int? = null,
- @JsonProperty("disallowed") var disallowed : Boolean? = null
-)
-
-class OkRuHttps: OkRu(){
- override var mainUrl = "https://ok.ru"
-}
-
-open class OkRu : ExtractorApi() {
- override var name = "Okru"
- override var mainUrl = "http://ok.ru"
- override val requiresReferer = false
-
- override suspend fun getUrl(url: String, referer: String?): List? {
- val doc = app.get(url).document
- val sources = ArrayList()
- val datajson = doc.select("div[data-options]").attr("data-options")
- if (datajson.isNotBlank()) {
- val main = parseJson(datajson)
- val metadatajson = parseJson(main.flashvars?.metadata!!)
- val servers = metadatajson.videos
- servers.forEach {
- val quality = it.name.uppercase()
- .replace("MOBILE","144p")
- .replace("LOWEST","240p")
- .replace("LOW","360p")
- .replace("SD","480p")
- .replace("HD","720p")
- .replace("FULL","1080p")
- .replace("QUAD","1440p")
- .replace("ULTRA","4k")
- val extractedurl = it.url.replace("\\\\u0026", "&")
- sources.add(ExtractorLink(
- name,
- name = this.name,
- extractedurl,
- url,
- getQualityFromName(quality),
- isM3u8 = false
- ))
- }
- }
- return sources
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/StreamSB.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/StreamSB.kt
deleted file mode 100644
index da3ef278..00000000
--- a/app/src/main/java/com/lagradost/cloudstream3/extractors/StreamSB.kt
+++ /dev/null
@@ -1,126 +0,0 @@
-package com.lagradost.cloudstream3.extractors
-
-import com.fasterxml.jackson.annotation.JsonProperty
-import com.lagradost.cloudstream3.app
-import com.lagradost.cloudstream3.utils.AppUtils.parseJson
-import com.lagradost.cloudstream3.utils.ExtractorApi
-import com.lagradost.cloudstream3.utils.ExtractorLink
-import com.lagradost.cloudstream3.utils.M3u8Helper
-
-class Ssbstream : StreamSB() {
- override var mainUrl = "https://ssbstream.net"
-}
-
-class SBfull : StreamSB() {
- override var mainUrl = "https://sbfull.com"
-}
-
-class StreamSB1 : StreamSB() {
- override var mainUrl = "https://sbplay1.com"
-}
-
-class StreamSB2 : StreamSB() {
- override var mainUrl = "https://sbplay2.com"
-}
-
-class StreamSB3 : StreamSB() {
- override var mainUrl = "https://sbplay3.com"
-}
-
-class StreamSB4 : StreamSB() {
- override var mainUrl = "https://cloudemb.com"
-}
-
-class StreamSB5 : StreamSB() {
- override var mainUrl = "https://sbplay.org"
-}
-
-class StreamSB6 : StreamSB() {
- override var mainUrl = "https://embedsb.com"
-}
-
-class StreamSB7 : StreamSB() {
- override var mainUrl = "https://pelistop.co"
-}
-
-class StreamSB8 : StreamSB() {
- override var mainUrl = "https://streamsb.net"
-}
-
-class StreamSB9 : StreamSB() {
- override var mainUrl = "https://sbplay.one"
-}
-
-class StreamSB10 : StreamSB() {
- override var mainUrl = "https://sbplay2.xyz"
-}
-
-// This is a modified version of https://github.com/jmir1/aniyomi-extensions/blob/master/src/en/genoanime/src/eu/kanade/tachiyomi/animeextension/en/genoanime/extractors/StreamSBExtractor.kt
-// The following code is under the Apache License 2.0 https://github.com/jmir1/aniyomi-extensions/blob/master/LICENSE
-open class StreamSB : ExtractorApi() {
- override var name = "StreamSB"
- override var mainUrl = "https://watchsb.com"
- override val requiresReferer = false
-
- private val hexArray = "0123456789ABCDEF".toCharArray()
-
- private fun bytesToHex(bytes: ByteArray): String {
- val hexChars = CharArray(bytes.size * 2)
- for (j in bytes.indices) {
- val v = bytes[j].toInt() and 0xFF
-
- hexChars[j * 2] = hexArray[v ushr 4]
- hexChars[j * 2 + 1] = hexArray[v and 0x0F]
- }
- return String(hexChars)
- }
-
- data class Subs (
- @JsonProperty("file") val file: String,
- @JsonProperty("label") val label: String,
- )
-
- data class StreamData (
- @JsonProperty("file") val file: String,
- @JsonProperty("cdn_img") val cdnImg: String,
- @JsonProperty("hash") val hash: String,
- @JsonProperty("subs") val subs: List?,
- @JsonProperty("length") val length: String,
- @JsonProperty("id") val id: String,
- @JsonProperty("title") val title: String,
- @JsonProperty("backup") val backup: String,
- )
-
- data class Main (
- @JsonProperty("stream_data") val streamData: StreamData,
- @JsonProperty("status_code") val statusCode: Int,
- )
-
- override suspend fun getUrl(url: String, referer: String?): List? {
- val regexID = Regex("(embed-[a-zA-Z0-9]{0,8}[a-zA-Z0-9_-]+|\\/e\\/[a-zA-Z0-9]{0,8}[a-zA-Z0-9_-]+)")
- val id = regexID.findAll(url).map {
- it.value.replace(Regex("(embed-|\\/e\\/)"),"")
- }.first()
- val bytes = id.toByteArray()
- val bytesToHex = bytesToHex(bytes)
- val master = "$mainUrl/sources43/6d6144797752744a454267617c7c${bytesToHex.lowercase()}7c7c4e61755a56456f34385243727c7c73747265616d7362/6b4a33767968506e4e71374f7c7c343837323439333133333462353935333633373836643638376337633462333634663539343137373761333635313533333835333763376333393636363133393635366136323733343435323332376137633763373337343732363536313664373336327c7c504d754478413835306633797c7c73747265616d7362"
- val headers = mapOf(
- "watchsb" to "streamsb",
- )
- val urltext = app.get(master,
- headers = headers,
- allowRedirects = false
- ).text
- val mapped = urltext.let { parseJson(it) }
- val testurl = app.get(mapped.streamData.file, headers = headers).text
- // val urlmain = mapped.streamData.file.substringBefore("/hls/")
- if (urltext.contains("m3u8") && testurl.contains("EXTM3U"))
- return M3u8Helper.generateM3u8(
- name,
- mapped.streamData.file,
- url,
- headers = headers
- )
- return null
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Tomatomatela.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Tomatomatela.kt
deleted file mode 100644
index 20bd69ba..00000000
--- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Tomatomatela.kt
+++ /dev/null
@@ -1,41 +0,0 @@
-package com.lagradost.cloudstream3.extractors
-
-import com.fasterxml.jackson.annotation.JsonProperty
-import com.lagradost.cloudstream3.app
-import com.lagradost.cloudstream3.utils.AppUtils.parseJson
-import com.lagradost.cloudstream3.utils.ExtractorApi
-import com.lagradost.cloudstream3.utils.ExtractorLink
-import com.lagradost.cloudstream3.utils.Qualities
-
-class Cinestart: Tomatomatela() {
- override var name = "Cinestart"
- override var mainUrl = "https://cinestart.net"
- override val details = "vr.php?v="
-}
-
-open class Tomatomatela : ExtractorApi() {
- override var name = "Tomatomatela"
- override var mainUrl = "https://tomatomatela.com"
- override val requiresReferer = false
- private data class Tomato (
- @JsonProperty("status") val status: Int,
- @JsonProperty("file") val file: String
- )
- open val details = "details.php?v="
- override suspend fun getUrl(url: String, referer: String?): List? {
- val link = url.replace("$mainUrl/embed.html#","$mainUrl/$details")
- val server = app.get(link, allowRedirects = false).text
- val json = parseJson(server)
- if (json.status == 200) return listOf(
- ExtractorLink(
- name,
- name,
- json.file,
- "",
- Qualities.Unknown.value,
- isM3u8 = false
- )
- )
- return null
- }
-}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/VidSrcExtractor.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/VidSrcExtractor.kt
deleted file mode 100644
index 7b087157..00000000
--- a/app/src/main/java/com/lagradost/cloudstream3/extractors/VidSrcExtractor.kt
+++ /dev/null
@@ -1,68 +0,0 @@
-package com.lagradost.cloudstream3.extractors
-
-import com.lagradost.cloudstream3.SubtitleFile
-import com.lagradost.cloudstream3.apmap
-import com.lagradost.cloudstream3.app
-import com.lagradost.cloudstream3.utils.ExtractorApi
-import com.lagradost.cloudstream3.utils.ExtractorLink
-import com.lagradost.cloudstream3.utils.M3u8Helper
-import com.lagradost.cloudstream3.utils.loadExtractor
-
-class VidSrcExtractor2 : VidSrcExtractor() {
- override val mainUrl = "https://vidsrc.me/embed"
- override suspend fun getUrl(
- url: String,
- referer: String?,
- subtitleCallback: (SubtitleFile) -> Unit,
- callback: (ExtractorLink) -> Unit
- ) {
- val newUrl = url.lowercase().replace(mainUrl, super.mainUrl)
- super.getUrl(newUrl, referer, subtitleCallback, callback)
- }
-}
-
-open class VidSrcExtractor : ExtractorApi() {
- override val name = "VidSrc"
- private val absoluteUrl = "https://v2.vidsrc.me"
- override val mainUrl = "$absoluteUrl/embed"
- override val requiresReferer = false
-
- override suspend fun getUrl(
- url: String,
- referer: String?,
- subtitleCallback: (SubtitleFile) -> Unit,
- callback: (ExtractorLink) -> Unit
- ) {
- val iframedoc = app.get(url).document
-
- val serverslist =
- iframedoc.select("div#sources.button_content div#content div#list div").map {
- val datahash = it.attr("data-hash")
- if (datahash.isNotBlank()) {
- val links = try {
- app.get("$absoluteUrl/src/$datahash", referer = "https://source.vidsrc.me/").url
- } catch (e: Exception) {
- ""
- }
- links
- } else ""
- }
-
- serverslist.apmap { server ->
- val linkfixed = server.replace("https://vidsrc.xyz/", "https://embedsito.com/")
- if (linkfixed.contains("/pro")) {
- val srcresponse = app.get(server, referer = absoluteUrl).text
- val m3u8Regex = Regex("((https:|http:)//.*\\.m3u8)")
- val srcm3u8 = m3u8Regex.find(srcresponse)?.value ?: return@apmap
- M3u8Helper.generateM3u8(
- name,
- srcm3u8,
- absoluteUrl
- ).forEach(callback)
- } else {
- loadExtractor(linkfixed, url, subtitleCallback, callback)
- }
- }
- }
-
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/VoeExtractor.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/VoeExtractor.kt
deleted file mode 100644
index d2f3f832..00000000
--- a/app/src/main/java/com/lagradost/cloudstream3/extractors/VoeExtractor.kt
+++ /dev/null
@@ -1,51 +0,0 @@
-package com.lagradost.cloudstream3.extractors
-
-import com.fasterxml.jackson.annotation.JsonProperty
-import com.lagradost.cloudstream3.app
-import com.lagradost.cloudstream3.utils.AppUtils.parseJson
-import com.lagradost.cloudstream3.utils.ExtractorApi
-import com.lagradost.cloudstream3.utils.ExtractorLink
-import com.lagradost.cloudstream3.utils.getQualityFromName
-
-open class VoeExtractor : ExtractorApi() {
- override val name: String = "Voe"
- override val mainUrl: String = "https://voe.sx"
- override val requiresReferer = false
-
- private data class ResponseLinks(
- @JsonProperty("hls") val url: String?,
- @JsonProperty("video_height") val label: Int?
- //val type: String // Mp4
- )
-
- override suspend fun getUrl(url: String, referer: String?): List {
- val extractedLinksList: MutableList = mutableListOf()
- val doc = app.get(url).text
- if (doc.isNotBlank()) {
- val start = "const sources ="
- var src = doc.substring(doc.indexOf(start))
- src = src.substring(start.length, src.indexOf(";"))
- .replace("0,", "0")
- .trim()
- //Log.i(this.name, "Result => (src) ${src}")
- parseJson(src)?.let { voelink ->
- //Log.i(this.name, "Result => (voelink) ${voelink}")
- val linkUrl = voelink.url
- val linkLabel = voelink.label?.toString() ?: ""
- if (!linkUrl.isNullOrEmpty()) {
- extractedLinksList.add(
- ExtractorLink(
- name = this.name,
- source = this.name,
- url = linkUrl,
- quality = getQualityFromName(linkLabel),
- referer = url,
- isM3u8 = true
- )
- )
- }
- }
- }
- return extractedLinksList
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/metaproviders/AnilistRedirector.kt b/app/src/main/java/com/lagradost/cloudstream3/metaproviders/AnilistRedirector.kt
deleted file mode 100644
index 208db14b..00000000
--- a/app/src/main/java/com/lagradost/cloudstream3/metaproviders/AnilistRedirector.kt
+++ /dev/null
@@ -1,30 +0,0 @@
-package com.lagradost.cloudstream3.metaproviders
-
-import com.lagradost.cloudstream3.ErrorLoadingException
-import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.SyncApis
-import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi
-import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi
-import com.lagradost.cloudstream3.utils.SyncUtil
-
-object SyncRedirector {
- val syncApis = SyncApis
-
- suspend fun redirect(url: String, preferredUrl: String): String {
- for (api in syncApis) {
- if (url.contains(api.mainUrl)) {
- val otherApi = when (api.name) {
- aniListApi.name -> "anilist"
- malApi.name -> "myanimelist"
- else -> return url
- }
-
- return SyncUtil.getUrlsFromId(api.getIdFromUrl(url), otherApi).firstOrNull { realUrl ->
- realUrl.contains(preferredUrl)
- } ?: run {
- throw ErrorLoadingException("Page does not exist on $preferredUrl")
- }
- }
- }
- return url
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/metaproviders/CrossTmdbProvider.kt b/app/src/main/java/com/lagradost/cloudstream3/metaproviders/CrossTmdbProvider.kt
index b01d188c..5bbb4538 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/metaproviders/CrossTmdbProvider.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/metaproviders/CrossTmdbProvider.kt
@@ -21,10 +21,11 @@ class CrossTmdbProvider : TmdbProvider() {
return Regex("""[^a-zA-Z0-9-]""").replace(name, "")
}
- private val validApis by lazy {
- apis.filter { it.lang == this.lang && it::class.java != this::class.java }
- //.distinctBy { it.uniqueId }
- }
+ private val validApis
+ get() =
+ synchronized(apis) { apis.filter { it.lang == this.lang && it::class.java != this::class.java } }
+ //.distinctBy { it.uniqueId }
+
data class CrossMetaData(
@JsonProperty("isSuccess") val isSuccess: Boolean,
@@ -39,7 +40,7 @@ class CrossTmdbProvider : TmdbProvider() {
): Boolean {
tryParseJson(data)?.let { metaData ->
if (!metaData.isSuccess) return false
- metaData.movies?.apmap { (apiName, data) ->
+ metaData.movies?.amap { (apiName, data) ->
getApiFromNameNull(apiName)?.let {
try {
it.loadLinks(data, isCasting, subtitleCallback, callback)
@@ -60,14 +61,15 @@ class CrossTmdbProvider : TmdbProvider() {
override suspend fun load(url: String): LoadResponse? {
val base = super.load(url)?.apply {
- this.recommendations = this.recommendations?.filterIsInstance() // TODO REMOVE
+ this.recommendations =
+ this.recommendations?.filterIsInstance() // TODO REMOVE
val matchName = filterName(this.name)
when (this) {
is MovieLoadResponse -> {
- val data = validApis.apmap { api ->
+ val data = validApis.amap { api ->
try {
if (api.supportedTypes.contains(TvType.Movie)) { //|| api.supportedTypes.contains(TvType.AnimeMovie)
- return@apmap api.search(this.name)?.first {
+ return@amap api.search(this.name)?.first {
if (filterName(it.name).equals(
matchName,
ignoreCase = true
@@ -98,6 +100,7 @@ class CrossTmdbProvider : TmdbProvider() {
this.dataUrl =
CrossMetaData(true, data.map { it.apiName to it.dataUrl }).toJson()
}
+
else -> {
throw ErrorLoadingException("Nothing besides movies are implemented for this provider")
}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/metaproviders/MultiAnimeProvider.kt b/app/src/main/java/com/lagradost/cloudstream3/metaproviders/MultiAnimeProvider.kt
deleted file mode 100644
index 0ab44b68..00000000
--- a/app/src/main/java/com/lagradost/cloudstream3/metaproviders/MultiAnimeProvider.kt
+++ /dev/null
@@ -1,70 +0,0 @@
-package com.lagradost.cloudstream3.metaproviders
-
-import com.lagradost.cloudstream3.*
-import com.lagradost.cloudstream3.LoadResponse.Companion.addAniListId
-import com.lagradost.cloudstream3.LoadResponse.Companion.addTrailer
-import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi
-import com.lagradost.cloudstream3.syncproviders.SyncAPI
-import com.lagradost.cloudstream3.syncproviders.providers.AniListApi
-import com.lagradost.cloudstream3.syncproviders.providers.MALApi
-import com.lagradost.cloudstream3.utils.SyncUtil
-
-// wont be implemented
-class MultiAnimeProvider : MainAPI() {
- override var name = "MultiAnime"
- override var lang = "en"
- override val usesWebView = true
- override val supportedTypes = setOf(TvType.Anime)
- private val syncApi: SyncAPI = aniListApi
-
- private val syncUtilType by lazy {
- when (syncApi) {
- is AniListApi -> "anilist"
- is MALApi -> "myanimelist"
- else -> throw ErrorLoadingException("Invalid Api")
- }
- }
-
- private val validApis by lazy {
- APIHolder.apis.filter {
- it.lang == this.lang && it::class.java != this::class.java && it.supportedTypes.contains(
- TvType.Anime
- )
- }
- }
-
- private fun filterName(name: String): String {
- return Regex("""[^a-zA-Z0-9-]""").replace(name, "")
- }
-
- override suspend fun search(query: String): List? {
- return syncApi.search(query)?.map {
- AnimeSearchResponse(it.name, it.url, this.name, TvType.Anime, it.posterUrl)
- }
- }
-
- override suspend fun load(url: String): LoadResponse? {
- return syncApi.getResult(url)?.let { res ->
- val data = SyncUtil.getUrlsFromId(res.id, syncUtilType).apmap { url ->
- validApis.firstOrNull { api -> url.startsWith(api.mainUrl) }?.load(url)
- }.filterNotNull()
-
- val type =
- if (data.any { it.type == TvType.AnimeMovie }) TvType.AnimeMovie else TvType.Anime
-
- newAnimeLoadResponse(
- res.title ?: throw ErrorLoadingException("No Title found"),
- url,
- type
- ) {
- posterUrl = res.posterUrl
- plot = res.synopsis
- tags = res.genres
- rating = res.publicScore
- addTrailer(res.trailers)
- addAniListId(res.id.toIntOrNull())
- recommendations = res.recommendations
- }
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/metaproviders/SyncRedirector.kt b/app/src/main/java/com/lagradost/cloudstream3/metaproviders/SyncRedirector.kt
new file mode 100644
index 00000000..bc646a8d
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/metaproviders/SyncRedirector.kt
@@ -0,0 +1,54 @@
+package com.lagradost.cloudstream3.metaproviders
+
+import com.lagradost.cloudstream3.MainAPI
+import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall
+import com.lagradost.cloudstream3.syncproviders.SyncIdName
+
+object SyncRedirector {
+ private val syncIds =
+ listOf(
+ SyncIdName.MyAnimeList to Regex("""myanimelist\.net/anime/(\d+)"""),
+ SyncIdName.Anilist to Regex("""anilist\.co/anime/(\d+)""")
+ )
+
+ suspend fun redirect(
+ url: String,
+ providerApi: MainAPI
+ ): String {
+ // Deprecated since providers should do this instead!
+
+ // Tries built in ID -> ProviderUrl
+ /*
+ for (api in syncApis) {
+ if (url.contains(api.mainUrl)) {
+ val otherApi = when (api.name) {
+ aniListApi.name -> "anilist"
+ malApi.name -> "myanimelist"
+ else -> return url
+ }
+
+ SyncUtil.getUrlsFromId(api.getIdFromUrl(url), otherApi).firstOrNull { realUrl ->
+ realUrl.contains(providerApi.mainUrl)
+ }?.let {
+ return it
+ }
+// ?: run {
+// throw ErrorLoadingException("Page does not exist on $preferredUrl")
+// }
+ }
+ }
+ */
+
+ // Tries provider solution
+ // This goes through all sync ids and finds supported id by said provider
+ return syncIds.firstNotNullOfOrNull { (syncName, syncRegex) ->
+ if (providerApi.supportedSyncNames.contains(syncName)) {
+ syncRegex.find(url)?.value?.let {
+ suspendSafeApiCall {
+ providerApi.getLoadUrl(syncName, it)
+ }
+ }
+ } else null
+ } ?: url
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/metaproviders/TmdbProvider.kt b/app/src/main/java/com/lagradost/cloudstream3/metaproviders/TmdbProvider.kt
index 314177af..c5b4d453 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/metaproviders/TmdbProvider.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/metaproviders/TmdbProvider.kt
@@ -105,6 +105,7 @@ open class TmdbProvider : MainAPI() {
this.id,
episode.episode_number,
episode.season_number,
+ this.name ?: this.original_name,
).toJson(),
episode.name,
episode.season_number,
@@ -122,6 +123,7 @@ open class TmdbProvider : MainAPI() {
this.id,
episodeNum,
season.season_number,
+ this.name ?: this.original_name,
).toJson(),
season = season.season_number
)
@@ -151,6 +153,8 @@ open class TmdbProvider : MainAPI() {
recommendations = (this@toLoadResponse.recommendations
?: this@toLoadResponse.similar)?.results?.map { it.toSearchResponse() }
addActors(credits?.cast?.toList().toActors())
+
+ contentRating = fetchContentRating(id, "US")
}
}
@@ -193,6 +197,8 @@ open class TmdbProvider : MainAPI() {
recommendations = (this@toLoadResponse.recommendations
?: this@toLoadResponse.similar)?.results?.map { it.toSearchResponse() }
addActors(credits?.cast?.toList().toActors())
+
+ contentRating = fetchContentRating(id, "US")
}
}
@@ -264,6 +270,26 @@ open class TmdbProvider : MainAPI() {
return null
}
+ open suspend fun fetchContentRating(id: Int?, country: String): String? {
+ id ?: return null
+
+ val contentRatings = tmdb.tvService().content_ratings(id).awaitResponse().body()?.results
+ return if (!contentRatings.isNullOrEmpty()) {
+ contentRatings.firstOrNull { it: ContentRating ->
+ it.iso_3166_1 == country
+ }?.rating
+ } else {
+ val releaseDates = tmdb.moviesService().releaseDates(id).awaitResponse().body()?.results
+ val certification = releaseDates?.firstOrNull { it: ReleaseDatesResult ->
+ it.iso_3166_1 == country
+ }?.release_dates?.firstOrNull { it: ReleaseDate ->
+ !it.certification.isNullOrBlank()
+ }?.certification
+
+ certification
+ }
+ }
+
// Possible to add recommendations and such here.
override suspend fun load(url: String): LoadResponse? {
// https://www.themoviedb.org/movie/7445-brothers
diff --git a/app/src/main/java/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt b/app/src/main/java/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt
new file mode 100644
index 00000000..addee9a0
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt
@@ -0,0 +1,471 @@
+package com.lagradost.cloudstream3.metaproviders
+
+import android.net.Uri
+import com.fasterxml.jackson.annotation.JsonAlias
+import com.fasterxml.jackson.annotation.JsonProperty
+import com.lagradost.cloudstream3.APIHolder
+import com.lagradost.cloudstream3.APIHolder.unixTimeMS
+import com.lagradost.cloudstream3.Actor
+import com.lagradost.cloudstream3.ActorData
+import com.lagradost.cloudstream3.Episode
+import com.lagradost.cloudstream3.HomePageResponse
+import com.lagradost.cloudstream3.LoadResponse
+import com.lagradost.cloudstream3.LoadResponse.Companion.addImdbId
+import com.lagradost.cloudstream3.LoadResponse.Companion.addTMDbId
+import com.lagradost.cloudstream3.LoadResponse.Companion.addTrailer
+import com.lagradost.cloudstream3.MainAPI
+import com.lagradost.cloudstream3.MainPageRequest
+import com.lagradost.cloudstream3.NextAiring
+import com.lagradost.cloudstream3.ProviderType
+import com.lagradost.cloudstream3.SearchResponse
+import com.lagradost.cloudstream3.ShowStatus
+import com.lagradost.cloudstream3.TvType
+import com.lagradost.cloudstream3.addDate
+import com.lagradost.cloudstream3.app
+import com.lagradost.cloudstream3.base64Decode
+import com.lagradost.cloudstream3.mainPageOf
+import com.lagradost.cloudstream3.mvvm.logError
+import com.lagradost.cloudstream3.newHomePageResponse
+import com.lagradost.cloudstream3.newMovieLoadResponse
+import com.lagradost.cloudstream3.newMovieSearchResponse
+import com.lagradost.cloudstream3.newTvSeriesLoadResponse
+import com.lagradost.cloudstream3.newTvSeriesSearchResponse
+import com.lagradost.cloudstream3.utils.AppUtils.parseJson
+import com.lagradost.cloudstream3.utils.AppUtils.toJson
+import java.text.SimpleDateFormat
+import java.util.Locale
+import kotlin.math.roundToInt
+
+open class TraktProvider : MainAPI() {
+ override var name = "Trakt"
+ override val hasMainPage = true
+ override val providerType = ProviderType.MetaProvider
+ override val supportedTypes = setOf(
+ TvType.Movie,
+ TvType.TvSeries,
+ TvType.Anime,
+ )
+
+ private val traktClientId =
+ base64Decode("N2YzODYwYWQzNGI4ZTZmOTdmN2I5MTA0ZWQzMzEwOGI0MmQ3MTdlMTM0MmM2NGMxMTg5NGE1MjUyYTQ3NjE3Zg==")
+ private val traktApiUrl = base64Decode("aHR0cHM6Ly9hcGl6LnRyYWt0LnR2")
+
+ override val mainPage = mainPageOf(
+ "$traktApiUrl/movies/trending" to "Trending Movies", //Most watched movies right now
+ "$traktApiUrl/movies/popular" to "Popular Movies", //The most popular movies for all time
+ "$traktApiUrl/shows/trending" to "Trending Shows", //Most watched Shows right now
+ "$traktApiUrl/shows/popular" to "Popular Shows", //The most popular Shows for all time
+ )
+
+ override suspend fun getMainPage(page: Int, request: MainPageRequest): HomePageResponse {
+
+ val apiResponse = getApi("${request.data}?extended=cloud9,full&page=$page")
+
+ val results = parseJson>(apiResponse).map { element ->
+ element.toSearchResponse()
+ }
+ return newHomePageResponse(request.name, results)
+ }
+
+ private fun MediaDetails.toSearchResponse(): SearchResponse {
+
+ val media = this.media ?: this
+ val mediaType = if (media.ids?.tvdb == null) TvType.Movie else TvType.TvSeries
+ val poster = media.images?.poster?.firstOrNull()
+
+ if (mediaType == TvType.Movie) {
+ return newMovieSearchResponse(
+ name = media.title!!,
+ url = Data(
+ type = mediaType,
+ mediaDetails = media,
+ ).toJson(),
+ type = TvType.Movie,
+ ) {
+ posterUrl = fixPath(poster)
+ }
+ } else {
+ return newTvSeriesSearchResponse(
+ name = media.title!!,
+ url = Data(
+ type = mediaType,
+ mediaDetails = media,
+ ).toJson(),
+ type = TvType.TvSeries,
+ ) {
+ this.posterUrl = fixPath(poster)
+ }
+ }
+ }
+
+ override suspend fun search(query: String): List? {
+ val apiResponse =
+ getApi("$traktApiUrl/search/movie,show?extended=cloud9,full&limit=20&page=1&query=$query")
+
+ val results = parseJson>(apiResponse).map { element ->
+ element.toSearchResponse()
+ }
+
+ return results
+ }
+
+ override suspend fun load(url: String): LoadResponse {
+
+ val data = parseJson(url)
+ val mediaDetails = data.mediaDetails
+ val moviesOrShows = if (data.type == TvType.Movie) "movies" else "shows"
+
+ val posterUrl = mediaDetails?.images?.poster?.firstOrNull()
+ val backDropUrl = mediaDetails?.images?.fanart?.firstOrNull()
+
+ val resActor =
+ getApi("$traktApiUrl/$moviesOrShows/${mediaDetails?.ids?.trakt}/people?extended=cloud9,full")
+
+ val actors = parseJson(resActor).cast?.map {
+ ActorData(
+ Actor(
+ name = it.person?.name!!,
+ image = getWidthImageUrl(it.person.images?.headshot?.firstOrNull(), "w500")
+ ),
+ roleString = it.character
+ )
+ }
+
+ val resRelated =
+ getApi("$traktApiUrl/$moviesOrShows/${mediaDetails?.ids?.trakt}/related?extended=cloud9,full&limit=20")
+
+ val relatedMedia = parseJson>(resRelated).map { it.toSearchResponse() }
+
+ val isCartoon =
+ mediaDetails?.genres?.contains("animation") == true || mediaDetails?.genres?.contains("anime") == true
+ val isAnime =
+ isCartoon && (mediaDetails?.language == "zh" || mediaDetails?.language == "ja")
+ val isAsian = !isAnime && (mediaDetails?.language == "zh" || mediaDetails?.language == "ko")
+ val isBollywood = mediaDetails?.country == "in"
+
+ if (data.type == TvType.Movie) {
+
+ val linkData = LinkData(
+ id = mediaDetails?.ids?.tmdb,
+ traktId = mediaDetails?.ids?.trakt,
+ traktSlug = mediaDetails?.ids?.slug,
+ tmdbId = mediaDetails?.ids?.tmdb,
+ imdbId = mediaDetails?.ids?.imdb.toString(),
+ tvdbId = mediaDetails?.ids?.tvdb,
+ tvrageId = mediaDetails?.ids?.tvrage,
+ type = data.type.toString(),
+ title = mediaDetails?.title,
+ year = mediaDetails?.year,
+ orgTitle = mediaDetails?.title,
+ isAnime = isAnime,
+ //jpTitle = later if needed as it requires another network request,
+ airedDate = mediaDetails?.released
+ ?: mediaDetails?.firstAired,
+ isAsian = isAsian,
+ isBollywood = isBollywood,
+ ).toJson()
+
+ return newMovieLoadResponse(
+ name = mediaDetails?.title!!,
+ url = data.toJson(),
+ dataUrl = linkData.toJson(),
+ type = if (isAnime) TvType.AnimeMovie else TvType.Movie,
+ ) {
+ this.name = mediaDetails.title
+ this.type = if (isAnime) TvType.AnimeMovie else TvType.Movie
+ this.posterUrl = getOriginalWidthImageUrl(posterUrl)
+ this.year = mediaDetails.year
+ this.plot = mediaDetails.overview
+ this.rating = mediaDetails.rating?.times(1000)?.roundToInt()
+ this.tags = mediaDetails.genres
+ this.duration = mediaDetails.runtime
+ this.recommendations = relatedMedia
+ this.actors = actors
+ this.comingSoon = isUpcoming(mediaDetails.released)
+ //posterHeaders
+ this.backgroundPosterUrl = getOriginalWidthImageUrl(backDropUrl)
+ this.contentRating = mediaDetails.certification
+ addTrailer(mediaDetails.trailer)
+ addImdbId(mediaDetails.ids?.imdb)
+ addTMDbId(mediaDetails.ids?.tmdb.toString())
+ }
+ } else {
+
+ val resSeasons =
+ getApi("$traktApiUrl/shows/${mediaDetails?.ids?.trakt.toString()}/seasons?extended=cloud9,full,episodes")
+ val episodes = mutableListOf()
+ val seasons = parseJson>(resSeasons)
+ var nextAir: NextAiring? = null
+
+ seasons.forEach { season ->
+
+ season.episodes?.map { episode ->
+
+ val linkData = LinkData(
+ id = mediaDetails?.ids?.tmdb,
+ traktId = mediaDetails?.ids?.trakt,
+ traktSlug = mediaDetails?.ids?.slug,
+ tmdbId = mediaDetails?.ids?.tmdb,
+ imdbId = mediaDetails?.ids?.imdb.toString(),
+ tvdbId = mediaDetails?.ids?.tvdb,
+ tvrageId = mediaDetails?.ids?.tvrage,
+ type = data.type.toString(),
+ season = episode.season,
+ episode = episode.number,
+ title = mediaDetails?.title,
+ year = mediaDetails?.year,
+ orgTitle = mediaDetails?.title,
+ isAnime = isAnime,
+ airedYear = mediaDetails?.year,
+ lastSeason = seasons.size,
+ epsTitle = episode.title,
+ //jpTitle = later if needed as it requires another network request,
+ date = episode.firstAired,
+ airedDate = episode.firstAired,
+ isAsian = isAsian,
+ isBollywood = isBollywood,
+ isCartoon = isCartoon
+ ).toJson()
+
+ episodes.add(
+ Episode(
+ data = linkData.toJson(),
+ name = episode.title,
+ season = episode.season,
+ episode = episode.number,
+ posterUrl = fixPath(episode.images?.screenshot?.firstOrNull()),
+ rating = episode.rating?.times(10)?.roundToInt(),
+ description = episode.overview,
+ runTime = episode.runtime
+ ).apply {
+ this.addDate(episode.firstAired, "yyyy-MM-dd'T'HH:mm:ss.SSSXXX")
+ if (nextAir == null && this.date != null && this.date!! > unixTimeMS && this.season != 0) {
+ nextAir = NextAiring(
+ episode = this.episode!!,
+ unixTime = this.date!!.div(1000L),
+ season = if (this.season == 1) null else this.season,
+ )
+ }
+ }
+ )
+ }
+ }
+
+ return newTvSeriesLoadResponse(
+ name = mediaDetails?.title!!,
+ url = data.toJson(),
+ type = if (isAnime) TvType.Anime else TvType.TvSeries,
+ episodes = episodes
+ ) {
+ this.name = mediaDetails.title
+ this.type = if (isAnime) TvType.Anime else TvType.TvSeries
+ this.episodes = episodes
+ this.posterUrl = getOriginalWidthImageUrl(posterUrl)
+ this.year = mediaDetails.year
+ this.plot = mediaDetails.overview
+ this.showStatus = getStatus(mediaDetails.status)
+ this.rating = mediaDetails.rating?.times(1000)?.roundToInt()
+ this.tags = mediaDetails.genres
+ this.duration = mediaDetails.runtime
+ this.recommendations = relatedMedia
+ this.actors = actors
+ this.comingSoon = isUpcoming(mediaDetails.released)
+ //posterHeaders
+ this.nextAiring = nextAir
+ this.backgroundPosterUrl = getOriginalWidthImageUrl(backDropUrl)
+ this.contentRating = mediaDetails.certification
+ addTrailer(mediaDetails.trailer)
+ addImdbId(mediaDetails.ids?.imdb)
+ addTMDbId(mediaDetails.ids?.tmdb.toString())
+ }
+ }
+ }
+
+ private suspend fun getApi(url: String): String {
+ return app.get(
+ url = url,
+ headers = mapOf(
+ "Content-Type" to "application/json",
+ "trakt-api-version" to "2",
+ "trakt-api-key" to traktClientId,
+ )
+ ).toString()
+ }
+
+ private fun isUpcoming(dateString: String?): Boolean {
+ return try {
+ val format = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
+ val dateTime = dateString?.let { format.parse(it)?.time } ?: return false
+ unixTimeMS < dateTime
+ } catch (t: Throwable) {
+ logError(t)
+ false
+ }
+ }
+
+ private fun getStatus(t: String?): ShowStatus {
+ return when (t) {
+ "returning series" -> ShowStatus.Ongoing
+ "continuing" -> ShowStatus.Ongoing
+ else -> ShowStatus.Completed
+ }
+ }
+
+ private fun fixPath(url: String?): String? {
+ url ?: return null
+ return "https://$url"
+ }
+
+ private fun getWidthImageUrl(path: String?, width: String): String? {
+ if (path == null) return null
+ if (!path.contains("image.tmdb.org")) return fixPath(path)
+ val fileName = Uri.parse(path).lastPathSegment ?: return null
+ return "https://image.tmdb.org/t/p/${width}/${fileName}"
+ }
+
+ private fun getOriginalWidthImageUrl(path: String?): String? {
+ if (path == null) return null
+ if (!path.contains("image.tmdb.org")) return fixPath(path)
+ return getWidthImageUrl(path, "original")
+ }
+
+ data class Data(
+ val type: TvType? = null,
+ val mediaDetails: MediaDetails? = null,
+ )
+
+ data class MediaDetails(
+ @JsonProperty("title") val title: String? = null,
+ @JsonProperty("year") val year: Int? = null,
+ @JsonProperty("ids") val ids: Ids? = null,
+ @JsonProperty("tagline") val tagline: String? = null,
+ @JsonProperty("overview") val overview: String? = null,
+ @JsonProperty("released") val released: String? = null,
+ @JsonProperty("runtime") val runtime: Int? = null,
+ @JsonProperty("country") val country: String? = null,
+ @JsonProperty("updatedAt") val updatedAt: String? = null,
+ @JsonProperty("trailer") val trailer: String? = null,
+ @JsonProperty("homepage") val homepage: String? = null,
+ @JsonProperty("status") val status: String? = null,
+ @JsonProperty("rating") val rating: Double? = null,
+ @JsonProperty("votes") val votes: Long? = null,
+ @JsonProperty("comment_count") val commentCount: Long? = null,
+ @JsonProperty("language") val language: String? = null,
+ @JsonProperty("languages") val languages: List? = null,
+ @JsonProperty("available_translations") val availableTranslations: List? = null,
+ @JsonProperty("genres") val genres: List? = null,
+ @JsonProperty("certification") val certification: String? = null,
+ @JsonProperty("aired_episodes") val airedEpisodes: Int? = null,
+ @JsonProperty("first_aired") val firstAired: String? = null,
+ @JsonProperty("airs") val airs: Airs? = null,
+ @JsonProperty("network") val network: String? = null,
+ @JsonProperty("images") val images: Images? = null,
+ @JsonProperty("movie") @JsonAlias("show") val media: MediaDetails? = null
+ )
+
+ data class Airs(
+ @JsonProperty("day") val day: String? = null,
+ @JsonProperty("time") val time: String? = null,
+ @JsonProperty("timezone") val timezone: String? = null,
+ )
+
+ data class Ids(
+ @JsonProperty("trakt") val trakt: Int? = null,
+ @JsonProperty("slug") val slug: String? = null,
+ @JsonProperty("tvdb") val tvdb: Int? = null,
+ @JsonProperty("imdb") val imdb: String? = null,
+ @JsonProperty("tmdb") val tmdb: Int? = null,
+ @JsonProperty("tvrage") val tvrage: String? = null,
+ )
+
+ data class Images(
+ @JsonProperty("fanart") val fanart: List? = null,
+ @JsonProperty("poster") val poster: List? = null,
+ @JsonProperty("logo") val logo: List? = null,
+ @JsonProperty("clearart") val clearart: List? = null,
+ @JsonProperty("banner") val banner: List? = null,
+ @JsonProperty("thumb") val thumb: List? = null,
+ @JsonProperty("screenshot") val screenshot: List? = null,
+ @JsonProperty("headshot") val headshot: List? = null,
+ )
+
+ data class People(
+ @JsonProperty("cast") val cast: List? = null,
+ )
+
+ data class Cast(
+ @JsonProperty("character") val character: String? = null,
+ @JsonProperty("characters") val characters: List? = null,
+ @JsonProperty("episode_count") val episodeCount: Long? = null,
+ @JsonProperty("person") val person: Person? = null,
+ @JsonProperty("images") val images: Images? = null,
+ )
+
+ data class Person(
+ @JsonProperty("name") val name: String? = null,
+ @JsonProperty("ids") val ids: Ids? = null,
+ @JsonProperty("images") val images: Images? = null,
+ )
+
+ data class Seasons(
+ @JsonProperty("aired_episodes") val airedEpisodes: Int? = null,
+ @JsonProperty("episode_count") val episodeCount: Int? = null,
+ @JsonProperty("episodes") val episodes: List? = null,
+ @JsonProperty("first_aired") val firstAired: String? = null,
+ @JsonProperty("ids") val ids: Ids? = null,
+ @JsonProperty("images") val images: Images? = null,
+ @JsonProperty("network") val network: String? = null,
+ @JsonProperty("number") val number: Int? = null,
+ @JsonProperty("overview") val overview: String? = null,
+ @JsonProperty("rating") val rating: Double? = null,
+ @JsonProperty("title") val title: String? = null,
+ @JsonProperty("updated_at") val updatedAt: String? = null,
+ @JsonProperty("votes") val votes: Int? = null,
+ )
+
+ data class TraktEpisode(
+ @JsonProperty("available_translations") val availableTranslations: List? = null,
+ @JsonProperty("comment_count") val commentCount: Int? = null,
+ @JsonProperty("episode_type") val episodeType: String? = null,
+ @JsonProperty("first_aired") val firstAired: String? = null,
+ @JsonProperty("ids") val ids: Ids? = null,
+ @JsonProperty("images") val images: Images? = null,
+ @JsonProperty("number") val number: Int? = null,
+ @JsonProperty("number_abs") val numberAbs: Int? = null,
+ @JsonProperty("overview") val overview: String? = null,
+ @JsonProperty("rating") val rating: Double? = null,
+ @JsonProperty("runtime") val runtime: Int? = null,
+ @JsonProperty("season") val season: Int? = null,
+ @JsonProperty("title") val title: String? = null,
+ @JsonProperty("updated_at") val updatedAt: String? = null,
+ @JsonProperty("votes") val votes: Int? = null,
+ )
+
+ data class LinkData(
+ val id: Int? = null,
+ val traktId: Int? = null,
+ val traktSlug: String? = null,
+ val tmdbId: Int? = null,
+ val imdbId: String? = null,
+ val tvdbId: Int? = null,
+ val tvrageId: String? = null,
+ val type: String? = null,
+ val season: Int? = null,
+ val episode: Int? = null,
+ val aniId: String? = null,
+ val animeId: String? = null,
+ val title: String? = null,
+ val year: Int? = null,
+ val orgTitle: String? = null,
+ val isAnime: Boolean = false,
+ val airedYear: Int? = null,
+ val lastSeason: Int? = null,
+ val epsTitle: String? = null,
+ val jpTitle: String? = null,
+ val date: String? = null,
+ val airedDate: String? = null,
+ val isAsian: Boolean = false,
+ val isBollywood: Boolean = false,
+ val isCartoon: Boolean = false,
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/mvvm/ArchComponentExt.kt b/app/src/main/java/com/lagradost/cloudstream3/mvvm/ArchComponentExt.kt
deleted file mode 100644
index df585cda..00000000
--- a/app/src/main/java/com/lagradost/cloudstream3/mvvm/ArchComponentExt.kt
+++ /dev/null
@@ -1,202 +0,0 @@
-package com.lagradost.cloudstream3.mvvm
-
-import android.util.Log
-import androidx.lifecycle.LifecycleOwner
-import androidx.lifecycle.LiveData
-import com.bumptech.glide.load.HttpException
-import com.lagradost.cloudstream3.BuildConfig
-import com.lagradost.cloudstream3.ErrorLoadingException
-import kotlinx.coroutines.*
-import java.net.SocketTimeoutException
-import java.net.UnknownHostException
-import javax.net.ssl.SSLHandshakeException
-import kotlin.coroutines.CoroutineContext
-import kotlin.coroutines.EmptyCoroutineContext
-
-const val DEBUG_EXCEPTION = "THIS IS A DEBUG EXCEPTION!"
-
-class DebugException(message: String) : Exception("$DEBUG_EXCEPTION\n$message")
-
-inline fun debugException(message: () -> String) {
- if (BuildConfig.DEBUG) {
- throw DebugException(message.invoke())
- }
-}
-
-inline fun debugWarning(message: () -> String) {
- if (BuildConfig.DEBUG) {
- logError(DebugException(message.invoke()))
- }
-}
-
-inline fun debugAssert(assert: () -> Boolean, message: () -> String) {
- if (BuildConfig.DEBUG && assert.invoke()) {
- throw DebugException(message.invoke())
- }
-}
-
-inline fun debugWarning(assert: () -> Boolean, message: () -> String) {
- if (BuildConfig.DEBUG && assert.invoke()) {
- logError(DebugException(message.invoke()))
- }
-}
-
-fun LifecycleOwner.observe(liveData: LiveData, action: (t: T) -> Unit) {
- liveData.observe(this) { it?.let { t -> action(t) } }
-}
-
-inline fun some(value: T?): Some {
- return if (value == null) {
- Some.None
- } else {
- Some.Success(value)
- }
-}
-
-sealed class Some {
- data class Success(val value: T) : Some()
- object None : Some()
-
- override fun toString(): String {
- return when (this) {
- is None -> "None"
- is Success -> "Some(${value.toString()})"
- }
- }
-}
-
-sealed class ResourceSome {
- data class Success(val value: T) : ResourceSome()
- object None : ResourceSome()
- data class Loading(val data: Any? = null) : ResourceSome()
-}
-
-sealed class Resource {
- data class Success(val value: T) : Resource()
- data class Failure(
- val isNetworkError: Boolean,
- val errorCode: Int?,
- val errorResponse: Any?, //ResponseBody
- val errorString: String,
- ) : Resource()
-
- data class Loading(val url: String? = null) : Resource()
-}
-
-fun logError(throwable: Throwable) {
- Log.d("ApiError", "-------------------------------------------------------------------")
- Log.d("ApiError", "safeApiCall: " + throwable.localizedMessage)
- Log.d("ApiError", "safeApiCall: " + throwable.message)
- throwable.printStackTrace()
- Log.d("ApiError", "-------------------------------------------------------------------")
-}
-
-fun normalSafeApiCall(apiCall: () -> T): T? {
- return try {
- apiCall.invoke()
- } catch (throwable: Throwable) {
- logError(throwable)
- return null
- }
-}
-
-suspend fun suspendSafeApiCall(apiCall: suspend () -> T): T? {
- return try {
- apiCall.invoke()
- } catch (throwable: Throwable) {
- logError(throwable)
- return null
- }
-}
-
-fun safeFail(throwable: Throwable): Resource {
- val stackTraceMsg =
- (throwable.localizedMessage ?: "") + "\n\n" + throwable.stackTrace.joinToString(
- separator = "\n"
- ) {
- "${it.fileName} ${it.lineNumber}"
- }
- return Resource.Failure(false, null, null, stackTraceMsg)
-}
-
-fun CoroutineScope.launchSafe(
- context: CoroutineContext = EmptyCoroutineContext,
- start: CoroutineStart = CoroutineStart.DEFAULT,
- block: suspend CoroutineScope.() -> Unit
-): Job {
- val obj: suspend CoroutineScope.() -> Unit = {
- try {
- block()
- } catch (e: Exception) {
- logError(e)
- }
- }
-
- return this.launch(context, start, obj)
-}
-
-suspend fun safeApiCall(
- apiCall: suspend () -> T,
-): Resource {
- return withContext(Dispatchers.IO) {
- try {
- Resource.Success(apiCall.invoke())
- } catch (throwable: Throwable) {
- logError(throwable)
- when (throwable) {
- is NullPointerException -> {
- for (line in throwable.stackTrace) {
- if (line?.fileName?.endsWith("provider.kt", ignoreCase = true) == true) {
- return@withContext Resource.Failure(
- false,
- null,
- null,
- "NullPointerException at ${line.fileName} ${line.lineNumber}\nSite might have updated or added Cloudflare/DDOS protection"
- )
- }
- }
- safeFail(throwable)
- }
- is SocketTimeoutException -> {
- Resource.Failure(
- true,
- null,
- null,
- "Connection Timeout\nPlease try again later."
- )
- }
- is HttpException -> {
- Resource.Failure(
- false,
- throwable.statusCode,
- null,
- throwable.message ?: "HttpException"
- )
- }
- is UnknownHostException -> {
- Resource.Failure(true, null, null, "Cannot connect to server, try again later.")
- }
- is ErrorLoadingException -> {
- Resource.Failure(
- true,
- null,
- null,
- throwable.message ?: "Error loading, try again later."
- )
- }
- is NotImplementedError -> {
- Resource.Failure(false, null, null, "This operation is not implemented.")
- }
- is SSLHandshakeException -> {
- Resource.Failure(
- true,
- null,
- null,
- (throwable.message ?: "SSLHandshakeException") + "\nTry again later."
- )
- }
- else -> safeFail(throwable)
- }
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/mvvm/Lifecycle.kt b/app/src/main/java/com/lagradost/cloudstream3/mvvm/Lifecycle.kt
new file mode 100644
index 00000000..3df5197c
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/mvvm/Lifecycle.kt
@@ -0,0 +1,16 @@
+package com.lagradost.cloudstream3.mvvm
+
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LiveData
+
+/** NOTE: Only one observer at a time per value */
+fun LifecycleOwner.observe(liveData: LiveData, action: (t: T) -> Unit) {
+ liveData.removeObservers(this)
+ liveData.observe(this) { it?.let { t -> action(t) } }
+}
+
+/** NOTE: Only one observer at a time per value */
+fun LifecycleOwner.observeNullable(liveData: LiveData, action: (t: T) -> Unit) {
+ liveData.removeObservers(this)
+ liveData.observe(this) { action(it) }
+}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/network/CloudflareKiller.kt b/app/src/main/java/com/lagradost/cloudstream3/network/CloudflareKiller.kt
index 7a759cd6..85a9db5d 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/network/CloudflareKiller.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/network/CloudflareKiller.kt
@@ -5,16 +5,23 @@ import android.webkit.CookieManager
import androidx.annotation.AnyThread
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.mvvm.debugWarning
+import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.nicehttp.Requests.Companion.await
import com.lagradost.nicehttp.cookies
import kotlinx.coroutines.runBlocking
-import okhttp3.*
+import okhttp3.Headers
+import okhttp3.Interceptor
+import okhttp3.Request
+import okhttp3.Response
+import java.net.URI
@AnyThread
class CloudflareKiller : Interceptor {
companion object {
const val TAG = "CloudflareKiller"
+ private val ERROR_CODES = listOf(403, 503)
+ private val CLOUDFLARE_SERVERS = listOf("cloudflare-nginx", "cloudflare")
fun parseCookieMap(cookie: String): Map {
return cookie.split(";").associate {
val split = it.split("=")
@@ -23,22 +30,56 @@ class CloudflareKiller : Interceptor {
}
}
- val savedCookies: MutableMap = mutableMapOf()
+ init {
+ // Needs to clear cookies between sessions to generate new cookies.
+ normalSafeApiCall {
+ // This can throw an exception on unsupported devices :(
+ CookieManager.getInstance().removeAllCookies(null)
+ }
+ }
+
+ val savedCookies: MutableMap> = mutableMapOf()
+
+ /**
+ * Gets the headers with cookies, webview user agent included!
+ * */
+ fun getCookieHeaders(url: String): Headers {
+ val userAgentHeaders = WebViewResolver.webViewUserAgent?.let {
+ mapOf("user-agent" to it)
+ } ?: emptyMap()
+
+ return getHeaders(userAgentHeaders, savedCookies[URI(url).host] ?: emptyMap())
+ }
override fun intercept(chain: Interceptor.Chain): Response = runBlocking {
val request = chain.request()
- if (savedCookies[request.url.host] == null) {
- bypassCloudflare(request)?.let {
- Log.d(TAG, "Succeeded bypassing cloudflare: ${request.url}")
- return@runBlocking it
+
+ when (val cookies = savedCookies[request.url.host]) {
+ null -> {
+ val response = chain.proceed(request)
+ if(!(response.header("Server") in CLOUDFLARE_SERVERS && response.code in ERROR_CODES)) {
+ return@runBlocking response
+ } else {
+ response.close()
+ bypassCloudflare(request)?.let {
+ Log.d(TAG, "Succeeded bypassing cloudflare: ${request.url}")
+ return@runBlocking it
+ }
+ }
+ }
+ else -> {
+ return@runBlocking proceed(request, cookies)
}
}
+
debugWarning({ true }) { "Failed cloudflare at: ${request.url}" }
return@runBlocking chain.proceed(request)
}
private fun getWebViewCookie(url: String): String? {
- return CookieManager.getInstance()?.getCookie(url)
+ return normalSafeApiCall {
+ CookieManager.getInstance()?.getCookie(url)
+ }
}
/**
@@ -49,11 +90,25 @@ class CloudflareKiller : Interceptor {
// Not sure if this takes expiration into account
return getWebViewCookie(request.url.toString())?.let { cookie ->
cookie.contains("cf_clearance").also { solved ->
- if (solved) savedCookies[request.url.host] = cookie
+ if (solved) savedCookies[request.url.host] = parseCookieMap(cookie)
}
} ?: false
}
+ private suspend fun proceed(request: Request, cookies: Map): Response {
+ val userAgentMap = WebViewResolver.getWebViewUserAgent()?.let {
+ mapOf("user-agent" to it)
+ } ?: emptyMap()
+
+ val headers =
+ getHeaders(request.headers.toMap() + userAgentMap, cookies + request.cookies)
+ return app.baseClient.newCall(
+ request.newBuilder()
+ .headers(headers)
+ .build()
+ ).await()
+ }
+
private suspend fun bypassCloudflare(request: Request): Response? {
val url = request.url.toString()
@@ -78,17 +133,6 @@ class CloudflareKiller : Interceptor {
}
val cookies = savedCookies[request.url.host] ?: return null
-
- val mappedCookies = parseCookieMap(cookies)
- val userAgentMap = WebViewResolver.getWebViewUserAgent()?.let {
- mapOf("user-agent" to it)
- } ?: emptyMap()
-
- val headers = getHeaders(request.headers.toMap() + userAgentMap, mappedCookies + request.cookies)
- return app.baseClient.newCall(
- request.newBuilder()
- .headers(headers)
- .build()
- ).await()
+ return proceed(request, cookies)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/network/DdosGuardKiller.kt b/app/src/main/java/com/lagradost/cloudstream3/network/DdosGuardKiller.kt
index dca3ee00..b5783f78 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/network/DdosGuardKiller.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/network/DdosGuardKiller.kt
@@ -2,7 +2,7 @@ package com.lagradost.cloudstream3.network
import androidx.annotation.AnyThread
import com.lagradost.cloudstream3.app
-import com.lagradost.nicehttp.Requests.Companion.await
+import com.lagradost.nicehttp.Requests
import com.lagradost.nicehttp.cookies
import kotlinx.coroutines.runBlocking
import okhttp3.Interceptor
@@ -41,7 +41,8 @@ class DdosGuardKiller(private val alwaysBypass: Boolean) : Interceptor {
savedCookiesMap[request.url.host]
// If no cookies are found fetch and save em.
?: (request.url.scheme + "://" + request.url.host + (ddosBypassPath ?: "")).let {
- app.get(it, cacheTime = 0).cookies.also { cookies ->
+ // Somehow app.get fails
+ Requests().get(it).cookies.also { cookies ->
savedCookiesMap[request.url.host] = cookies
}
}
@@ -51,6 +52,6 @@ class DdosGuardKiller(private val alwaysBypass: Boolean) : Interceptor {
request.newBuilder()
.headers(headers)
.build()
- ).await()
+ ).execute()
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/network/DohProviders.kt b/app/src/main/java/com/lagradost/cloudstream3/network/DohProviders.kt
index 5372d0be..55e09251 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/network/DohProviders.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/network/DohProviders.kt
@@ -64,4 +64,24 @@ fun OkHttpClient.Builder.addAdGuardDns() = (
"94.140.14.140",
"94.140.14.141",
)
- ))
\ No newline at end of file
+ ))
+
+fun OkHttpClient.Builder.addDNSWatchDns() = (
+ addGenericDns(
+ "https://resolver2.dns.watch/dns-query",
+ // https://dns.watch/
+ listOf(
+ "84.200.69.80",
+ "84.200.70.40",
+ )
+ ))
+
+fun OkHttpClient.Builder.addQuad9Dns() = (
+ addGenericDns(
+ "https://dns.quad9.net/dns-query",
+ // https://www.quad9.net/service/service-addresses-and-features
+ listOf(
+ "9.9.9.9",
+ "149.112.112.112",
+ )
+ ))
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt
index 85e9d318..a1d84f6c 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt
@@ -4,19 +4,19 @@ import android.content.Context
import androidx.preference.PreferenceManager
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.USER_AGENT
+import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.nicehttp.Requests
-import com.lagradost.nicehttp.getCookies
import com.lagradost.nicehttp.ignoreAllSSLErrors
import okhttp3.Cache
import okhttp3.Headers
import okhttp3.Headers.Companion.toHeaders
import okhttp3.OkHttpClient
-import okhttp3.Request
+import org.conscrypt.Conscrypt
import java.io.File
-import java.util.concurrent.TimeUnit
-
+import java.security.Security
fun Requests.initClient(context: Context): OkHttpClient {
+ normalSafeApiCall { Security.insertProviderAt(Conscrypt.newProvider(), 1) }
val settingsManager = PreferenceManager.getDefaultSharedPreferences(context)
val dns = settingsManager.getInt(context.getString(R.string.dns_pref), 0)
baseClient = OkHttpClient.Builder()
@@ -36,6 +36,8 @@ fun Requests.initClient(context: Context): OkHttpClient {
2 -> addCloudFlareDns()
// 3 -> addOpenDns()
4 -> addAdGuardDns()
+ 5 -> addDNSWatchDns()
+ 6 -> addQuad9Dns()
}
}
// Needs to be build as otherwise the other builders will change this object
diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/CloudstreamPlugin.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/CloudstreamPlugin.kt
index e89ccfeb..ddf5b286 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/plugins/CloudstreamPlugin.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/CloudstreamPlugin.kt
@@ -2,5 +2,4 @@ package com.lagradost.cloudstream3.plugins
@Suppress("unused")
@Target(AnnotationTarget.CLASS)
-annotation class CloudstreamPlugin(
-)
\ No newline at end of file
+annotation class CloudstreamPlugin
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/Plugin.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/Plugin.kt
index 242baf59..fc836587 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/plugins/Plugin.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/Plugin.kt
@@ -34,9 +34,11 @@ abstract class Plugin {
*/
fun registerMainAPI(element: MainAPI) {
Log.i(PLUGIN_TAG, "Adding ${element.name} (${element.mainUrl}) MainAPI")
- element.sourcePlugin = this.__filename
+ element.sourcePlugin = this.filename
// Race condition causing which would case duplicates if not for distinctBy
- APIHolder.allProviders.add(element)
+ synchronized(APIHolder.allProviders) {
+ APIHolder.allProviders.add(element)
+ }
APIHolder.addPluginMapping(element)
}
@@ -46,22 +48,31 @@ abstract class Plugin {
*/
fun registerExtractorAPI(element: ExtractorApi) {
Log.i(PLUGIN_TAG, "Adding ${element.name} (${element.mainUrl}) ExtractorApi")
- element.sourcePlugin = this.__filename
+ element.sourcePlugin = this.filename
extractorApis.add(element)
}
class Manifest {
- @JsonProperty("name") var name: String? = null
- @JsonProperty("pluginClassName") var pluginClassName: String? = null
- @JsonProperty("version") var version: Int? = null
- @JsonProperty("requiresResources") var requiresResources: Boolean = false
+ @JsonProperty("name")
+ var name: String? = null
+ @JsonProperty("pluginClassName")
+ var pluginClassName: String? = null
+ @JsonProperty("version")
+ var version: Int? = null
+ @JsonProperty("requiresResources")
+ var requiresResources: Boolean = false
}
/**
* This will contain your resources if you specified requiresResources in gradle
*/
var resources: Resources? = null
- var __filename: String? = null
+ /** Full file path to the plugin. */
+ @Deprecated("Renamed to `filename` to follow conventions", replaceWith = ReplaceWith("filename"))
+ var __filename: String?
+ get() = filename
+ set(value) {filename = value}
+ var filename: String? = null
/**
* This will add a button in the settings allowing you to add custom settings
diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt
index 93bc85bf..bc2a1780 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt
@@ -1,30 +1,47 @@
package com.lagradost.cloudstream3.plugins
-import dalvik.system.PathClassLoader
-import com.google.gson.Gson
+import android.Manifest
+import android.app.*
+import android.content.Context
+import android.content.pm.PackageManager
import android.content.res.AssetManager
import android.content.res.Resources
+import android.os.Build
import android.os.Environment
-import android.widget.Toast
-import android.app.Activity
import android.util.Log
+import android.widget.Toast
+import androidx.core.app.ActivityCompat
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.fragment.app.FragmentActivity
import com.fasterxml.jackson.annotation.JsonProperty
+import com.google.gson.Gson
import com.lagradost.cloudstream3.*
+import com.lagradost.cloudstream3.APIHolder.removePluginMapping
+import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
-import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
-import com.lagradost.cloudstream3.plugins.RepositoryManager.ONLINE_PLUGINS_FOLDER
-import com.lagradost.cloudstream3.plugins.RepositoryManager.downloadPluginToFile
import com.lagradost.cloudstream3.CommonActivity.showToast
+import com.lagradost.cloudstream3.MainAPI.Companion.settingsForProvider
+import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent
+import com.lagradost.cloudstream3.mvvm.debugPrint
+import com.lagradost.cloudstream3.mvvm.logError
+import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
+import com.lagradost.cloudstream3.plugins.RepositoryManager.ONLINE_PLUGINS_FOLDER
+import com.lagradost.cloudstream3.plugins.RepositoryManager.PREBUILT_REPOSITORIES
+import com.lagradost.cloudstream3.plugins.RepositoryManager.downloadPluginToFile
import com.lagradost.cloudstream3.plugins.RepositoryManager.getRepoPlugins
+import com.lagradost.cloudstream3.ui.result.UiText
+import com.lagradost.cloudstream3.ui.result.txt
import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY
import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData
-import com.lagradost.cloudstream3.utils.VideoDownloadManager.sanitizeFilename
-import com.lagradost.cloudstream3.APIHolder.removePluginMapping
-import com.lagradost.cloudstream3.mvvm.logError
-import com.lagradost.cloudstream3.plugins.RepositoryManager.PREBUILT_REPOSITORIES
+import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings
+import com.lagradost.cloudstream3.utils.Coroutines.main
import com.lagradost.cloudstream3.utils.ExtractorApi
+import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
+import com.lagradost.cloudstream3.utils.VideoDownloadManager.sanitizeFilename
import com.lagradost.cloudstream3.utils.extractorApis
+import dalvik.system.PathClassLoader
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.File
@@ -35,6 +52,9 @@ import java.util.*
const val PLUGINS_KEY = "PLUGINS_KEY"
const val PLUGINS_KEY_LOCAL = "PLUGINS_KEY_LOCAL"
+const val EXTENSIONS_CHANNEL_ID = "cloudstream3.extensions"
+const val EXTENSIONS_CHANNEL_NAME = "Extensions"
+const val EXTENSIONS_CHANNEL_DESCRIPT = "Extension notification channel"
// Data class for internal storage
data class PluginData(
@@ -75,6 +95,8 @@ object PluginManager {
const val TAG = "PluginManager"
+ private var hasCreatedNotChanel = false
+
/**
* Store data about the plugin for fetching later
* */
@@ -109,10 +131,28 @@ object PluginManager {
val plugins = getPluginsOnline().filter {
!it.filePath.contains(repositoryPath)
}
+ val file = File(repositoryPath)
+ normalSafeApiCall {
+ if (file.exists()) file.deleteRecursively()
+ }
setKey(PLUGINS_KEY, plugins)
}
}
+ /**
+ * Deletes all generated oat files which will force Android to recompile the dex extensions.
+ * This might fix unrecoverable SIGSEGV exceptions when old oat files are loaded in a new app update.
+ */
+ fun deleteAllOatFiles(context: Context) {
+ File("${context.filesDir}/${ONLINE_PLUGINS_FOLDER}").listFiles()?.forEach { repo ->
+ repo.listFiles { file -> file.name == "oat" && file.isDirectory }?.forEach { file ->
+ val success = file.deleteRecursively()
+ Log.i(TAG, "Deleted oat directory: ${file.absolutePath} Success=$success")
+ }
+ }
+ }
+
+
fun getPluginsOnline(): Array {
return getKey(PLUGINS_KEY) ?: emptyArray()
}
@@ -121,11 +161,15 @@ object PluginManager {
return getKey(PLUGINS_KEY_LOCAL) ?: emptyArray()
}
- private val LOCAL_PLUGINS_PATH =
- Environment.getExternalStorageDirectory().absolutePath + "/Cloudstream3/plugins"
+ private val CLOUD_STREAM_FOLDER =
+ Environment.getExternalStorageDirectory().absolutePath + "/Cloudstream3/"
+
+ private val LOCAL_PLUGINS_PATH = CLOUD_STREAM_FOLDER + "plugins"
+
+ var currentlyLoading: String? = null
// Maps filepath to plugin
- private val plugins: MutableMap =
+ val plugins: MutableMap =
LinkedHashMap()
// Maps urls to plugin
@@ -135,14 +179,18 @@ object PluginManager {
private val classLoaders: MutableMap =
HashMap()
- private var loadedLocalPlugins = false
+ var loadedLocalPlugins = false
+ private set
+
+ var loadedOnlinePlugins = false
+ private set
private val gson = Gson()
- private suspend fun maybeLoadPlugin(activity: Activity, file: File) {
+ private suspend fun maybeLoadPlugin(context: Context, file: File) {
val name = file.name
if (file.extension == "zip" || file.extension == "cs3") {
loadPlugin(
- activity,
+ context,
file,
PluginData(name, null, false, file.absolutePath, PLUGIN_VERSION_NOT_SET)
)
@@ -158,18 +206,31 @@ object PluginManager {
val onlineData: Pair,
) {
val isOutdated =
- onlineData.second.version != savedData.version || onlineData.second.version == PLUGIN_VERSION_ALWAYS_UPDATE
+ onlineData.second.version > savedData.version || onlineData.second.version == PLUGIN_VERSION_ALWAYS_UPDATE
val isDisabled = onlineData.second.status == PROVIDER_STATUS_DOWN
+
+ fun validOnlineData(context: Context): Boolean {
+ return getPluginPath(
+ context,
+ savedData.internalName,
+ onlineData.first
+ ).absolutePath == savedData.filePath
+ }
}
- var allCurrentOutDatedPlugins: Set = emptySet()
+ // var allCurrentOutDatedPlugins: Set = emptySet()
- suspend fun loadSinglePlugin(activity: Activity, apiName: String): Boolean {
- return (getPluginsOnline().firstOrNull { it.internalName == apiName }
- ?: getPluginsLocal().firstOrNull { it.internalName == apiName })?.let { savedData ->
+ suspend fun loadSinglePlugin(context: Context, apiName: String): Boolean {
+ return (getPluginsOnline().firstOrNull {
+ // Most of the time the provider ends with Provider which isn't part of the api name
+ it.internalName.replace("provider", "", ignoreCase = true) == apiName
+ }
+ ?: getPluginsLocal().firstOrNull {
+ it.internalName.replace("provider", "", ignoreCase = true) == apiName
+ })?.let { savedData ->
// OnlinePluginData(savedData, onlineData)
loadPlugin(
- activity,
+ context,
File(savedData.filePath),
savedData
)
@@ -184,6 +245,10 @@ object PluginManager {
* 4. Else load the plugin normally
**/
fun updateAllOnlinePluginsAndLoadThem(activity: Activity) {
+ // Load all plugins as fast as possible!
+ loadAllOnlinePlugins(activity)
+ afterPluginsLoadedEvent.invoke(false)
+
val urls = (getKey>(REPOSITORIES_KEY)
?: emptyArray()) + PREBUILT_REPOSITORIES
@@ -193,50 +258,179 @@ object PluginManager {
// Iterates over all offline plugins, compares to remote repo and returns the plugins which are outdated
val outdatedPlugins = getPluginsOnline().map { savedData ->
- onlinePlugins.filter { onlineData -> savedData.internalName == onlineData.second.internalName }
+ onlinePlugins
+ .filter { onlineData -> savedData.internalName == onlineData.second.internalName }
.map { onlineData ->
OnlinePluginData(savedData, onlineData)
+ }.filter {
+ it.validOnlineData(activity)
}
}.flatten().distinctBy { it.onlineData.second.url }
- allCurrentOutDatedPlugins = outdatedPlugins.toSet()
- Log.i(TAG, "Outdated plugins: ${outdatedPlugins.filter { it.isOutdated }}")
+ debugPrint {
+ "Outdated plugins: ${outdatedPlugins.filter { it.isOutdated }}"
+ }
- outdatedPlugins.apmap {
- if (it.isDisabled) {
- return@apmap
- } else if (it.isOutdated) {
- downloadAndLoadPlugin(
+ val updatedPlugins = mutableListOf()
+
+ outdatedPlugins.apmap { pluginData ->
+ if (pluginData.isDisabled) {
+ //updatedPlugins.add(activity.getString(R.string.single_plugin_disabled, pluginData.onlineData.second.name))
+ unloadPlugin(pluginData.savedData.filePath)
+ } else if (pluginData.isOutdated) {
+ downloadPlugin(
activity,
- it.onlineData.second.url,
- it.savedData.internalName,
- it.onlineData.first
- )
- } else {
- loadPlugin(
- activity,
- File(it.savedData.filePath),
- it.savedData
- )
+ pluginData.onlineData.second.url,
+ pluginData.savedData.internalName,
+ File(pluginData.savedData.filePath),
+ true
+ ).let { success ->
+ if (success)
+ updatedPlugins.add(pluginData.onlineData.second.name)
+ }
}
}
+ main {
+ val uitext = txt(R.string.plugins_updated, updatedPlugins.size)
+ createNotification(activity, uitext, updatedPlugins)
+ }
+
+ // ioSafe {
+ loadedOnlinePlugins = true
+ afterPluginsLoadedEvent.invoke(false)
+ // }
+
Log.i(TAG, "Plugin update done!")
}
/**
- * Use updateAllOnlinePluginsAndLoadThem
- * */
- fun loadAllOnlinePlugins(activity: Activity) {
- File(activity.filesDir, ONLINE_PLUGINS_FOLDER).listFiles()?.sortedBy { it.name }
- ?.apmap { file ->
- maybeLoadPlugin(activity, file)
+ * Automatically download plugins not yet existing on local
+ * 1. Gets all online data from online plugins repo
+ * 2. Fetch all not downloaded plugins
+ * 3. Download them and reload plugins
+ **/
+ fun downloadNotExistingPluginsAndLoad(activity: Activity, mode: AutoDownloadMode) {
+ val newDownloadPlugins = mutableListOf()
+ val urls = (getKey>(REPOSITORIES_KEY)
+ ?: emptyArray()) + PREBUILT_REPOSITORIES
+ val onlinePlugins = urls.toList().apmap {
+ getRepoPlugins(it.url)?.toList() ?: emptyList()
+ }.flatten().distinctBy { it.second.url }
+
+ val providerLang = activity.getApiProviderLangSettings()
+ //Log.i(TAG, "providerLang => ${providerLang.toJson()}")
+
+ // Iterate online repos and returns not downloaded plugins
+ val notDownloadedPlugins = onlinePlugins.mapNotNull { onlineData ->
+ val sitePlugin = onlineData.second
+ val tvtypes = sitePlugin.tvTypes ?: listOf()
+
+ //Don't include empty urls
+ if (sitePlugin.url.isBlank()) {
+ return@mapNotNull null
}
+ if (sitePlugin.repositoryUrl.isNullOrBlank()) {
+ return@mapNotNull null
+ }
+
+ //Omit already existing plugins
+ if (getPluginPath(activity, sitePlugin.internalName, onlineData.first).exists()) {
+ Log.i(TAG, "Skip > ${sitePlugin.internalName}")
+ return@mapNotNull null
+ }
+
+ //Omit non-NSFW if mode is set to NSFW only
+ if (mode == AutoDownloadMode.NsfwOnly) {
+ if (!tvtypes.contains(TvType.NSFW.name)) {
+ return@mapNotNull null
+ }
+ }
+ //Omit NSFW, if disabled
+ if (!settingsForProvider.enableAdult) {
+ if (tvtypes.contains(TvType.NSFW.name)) {
+ return@mapNotNull null
+ }
+ }
+
+ //Omit lang not selected on language setting
+ if (mode == AutoDownloadMode.FilterByLang) {
+ val lang = sitePlugin.language ?: return@mapNotNull null
+ //If set to 'universal', don't skip any language
+ if (!providerLang.contains(AllLanguagesName) && !providerLang.contains(lang)) {
+ return@mapNotNull null
+ }
+ //Log.i(TAG, "sitePlugin lang => $lang")
+ }
+
+ val savedData = PluginData(
+ url = sitePlugin.url,
+ internalName = sitePlugin.internalName,
+ isOnline = true,
+ filePath = "",
+ version = sitePlugin.version
+ )
+ OnlinePluginData(savedData, onlineData)
+ }
+ //Log.i(TAG, "notDownloadedPlugins => ${notDownloadedPlugins.toJson()}")
+
+ notDownloadedPlugins.apmap { pluginData ->
+ downloadPlugin(
+ activity,
+ pluginData.onlineData.second.url,
+ pluginData.savedData.internalName,
+ pluginData.onlineData.first,
+ !pluginData.isDisabled
+ ).let { success ->
+ if (success)
+ newDownloadPlugins.add(pluginData.onlineData.second.name)
+ }
+ }
+
+ main {
+ val uitext = txt(R.string.plugins_downloaded, newDownloadPlugins.size)
+ createNotification(activity, uitext, newDownloadPlugins)
+ }
+
+ // ioSafe {
+ afterPluginsLoadedEvent.invoke(false)
+ // }
+
+ Log.i(TAG, "Plugin download done!")
}
- fun loadAllLocalPlugins(activity: Activity) {
+ /**
+ * Use updateAllOnlinePluginsAndLoadThem
+ * */
+ fun loadAllOnlinePlugins(context: Context) {
+ // Load all plugins as fast as possible!
+ (getPluginsOnline()).toList().apmap { pluginData ->
+ loadPlugin(
+ context,
+ File(pluginData.filePath),
+ pluginData
+ )
+ }
+ }
+
+ /**
+ * Reloads all local plugins and forces a page update, used for hot reloading with deployWithAdb
+ **/
+ fun hotReloadAllLocalPlugins(activity: FragmentActivity?) {
+ Log.d(TAG, "Reloading all local plugins!")
+ if (activity == null) return
+ getPluginsLocal().forEach {
+ unloadPlugin(it.filePath)
+ }
+ loadAllLocalPlugins(activity, true)
+ }
+
+ /**
+ * @param forceReload see afterPluginsLoadedEvent, basically a way to load all local plugins
+ * and reload all pages even if they are previously valid
+ **/
+ fun loadAllLocalPlugins(context: Context, forceReload: Boolean) {
val dir = File(LOCAL_PLUGINS_PATH)
- removeKey(PLUGINS_KEY_LOCAL)
if (!dir.exists()) {
val res = dir.mkdirs()
@@ -252,22 +446,47 @@ object PluginManager {
Log.d(TAG, "Files in '${LOCAL_PLUGINS_PATH}' folder: $sortedPlugins")
sortedPlugins?.sortedBy { it.name }?.apmap { file ->
- maybeLoadPlugin(activity, file)
+ maybeLoadPlugin(context, file)
}
loadedLocalPlugins = true
+ afterPluginsLoadedEvent.invoke(forceReload)
+ }
+
+ /**
+ * This can be used to override any extension loading to fix crashes!
+ * @return true if safe mode file is present
+ **/
+ fun checkSafeModeFile(): Boolean {
+ return normalSafeApiCall {
+ val folder = File(CLOUD_STREAM_FOLDER)
+ if (!folder.exists()) return@normalSafeApiCall false
+ val files = folder.listFiles { _, name ->
+ name.equals("safe", ignoreCase = true)
+ }
+ files?.any()
+ } ?: false
}
/**
* @return True if successful, false if not
* */
- private suspend fun loadPlugin(activity: Activity, file: File, data: PluginData): Boolean {
+ private suspend fun loadPlugin(context: Context, file: File, data: PluginData): Boolean {
val fileName = file.nameWithoutExtension
val filePath = file.absolutePath
+ currentlyLoading = fileName
Log.i(TAG, "Loading plugin: $data")
return try {
- val loader = PathClassLoader(filePath, activity.classLoader)
+ // in case of android 14 then
+ try {
+ File(filePath).setReadOnly()
+ } catch (t: Throwable) {
+ Log.e(TAG, "Failed to set dex as readonly")
+ logError(t)
+ }
+
+ val loader = PathClassLoader(filePath, context.classLoader)
var manifest: Plugin.Manifest
loader.getResourceAsStream("manifest.json").use { stream ->
if (stream == null) {
@@ -288,10 +507,12 @@ object PluginManager {
val version: Int = manifest.version ?: PLUGIN_VERSION_NOT_SET.also {
Log.d(TAG, "No manifest version for ${data.internalName}")
}
+
+ @Suppress("UNCHECKED_CAST")
val pluginClass: Class<*> =
loader.loadClass(manifest.pluginClassName) as Class
val pluginInstance: Plugin =
- pluginClass.newInstance() as Plugin
+ pluginClass.getDeclaredConstructor().newInstance() as Plugin
// Sets with the proper version
setPluginData(data.copy(version = version))
@@ -301,40 +522,42 @@ object PluginManager {
return true
}
- pluginInstance.__filename = fileName
+ pluginInstance.filename = file.absolutePath
if (manifest.requiresResources) {
Log.d(TAG, "Loading resources for ${data.internalName}")
// based on https://stackoverflow.com/questions/7483568/dynamic-resource-loading-from-other-apk
- val assets = AssetManager::class.java.newInstance()
+ val assets = AssetManager::class.java.getDeclaredConstructor().newInstance()
val addAssetPath =
AssetManager::class.java.getMethod("addAssetPath", String::class.java)
addAssetPath.invoke(assets, file.absolutePath)
+
+ @Suppress("DEPRECATION")
pluginInstance.resources = Resources(
assets,
- activity.resources.displayMetrics,
- activity.resources.configuration
+ context.resources.displayMetrics,
+ context.resources.configuration
)
}
plugins[filePath] = pluginInstance
classLoaders[loader] = pluginInstance
- if (data.url != null) { // TODO: make this cleaner
- urlPlugins[data.url] = pluginInstance
- }
- pluginInstance.load(activity)
+ urlPlugins[data.url ?: filePath] = pluginInstance
+ pluginInstance.load(context)
Log.i(TAG, "Loaded plugin ${data.internalName} successfully")
+ currentlyLoading = null
true
} catch (e: Throwable) {
Log.e(TAG, "Failed to load $file: ${Log.getStackTraceString(e)}")
showToast(
- activity,
- activity.getString(R.string.plugin_load_fail).format(fileName),
+ context.getActivity(),
+ context.getString(R.string.plugin_load_fail).format(fileName),
Toast.LENGTH_LONG
)
+ currentlyLoading = null
false
}
}
- private fun unloadPlugin(absolutePath: String) {
+ fun unloadPlugin(absolutePath: String) {
Log.i(TAG, "Unloading plugin: $absolutePath")
val plugin = plugins[absolutePath]
if (plugin == null) {
@@ -349,15 +572,20 @@ object PluginManager {
}
// remove all registered apis
- APIHolder.apis.filter { api -> api.sourcePlugin == plugin.__filename }.forEach {
- removePluginMapping(it)
+ synchronized(APIHolder.apis) {
+ APIHolder.apis.filter { api -> api.sourcePlugin == plugin.filename }.forEach {
+ removePluginMapping(it)
+ }
}
- APIHolder.allProviders.removeIf { provider: MainAPI -> provider.sourcePlugin == plugin.__filename }
- extractorApis.removeIf { provider: ExtractorApi -> provider.sourcePlugin == plugin.__filename }
+ synchronized(APIHolder.allProviders) {
+ APIHolder.allProviders.removeIf { provider: MainAPI -> provider.sourcePlugin == plugin.filename }
+ }
+ extractorApis.removeIf { provider: ExtractorApi -> provider.sourcePlugin == plugin.filename }
classLoaders.values.removeIf { v -> v == plugin }
plugins.remove(absolutePath)
+ urlPlugins.values.removeIf { v -> v == plugin }
}
/**
@@ -371,41 +599,75 @@ object PluginManager {
) + "." + name.hashCode()
}
- suspend fun downloadAndLoadPlugin(
+ /**
+ * This should not be changed as it is used to also detect if a plugin is installed!
+ **/
+ fun getPluginPath(
+ context: Context,
+ internalName: String,
+ repositoryUrl: String
+ ): File {
+ val folderName = getPluginSanitizedFileName(repositoryUrl) // Guaranteed unique
+ val fileName = getPluginSanitizedFileName(internalName)
+ return File("${context.filesDir}/${ONLINE_PLUGINS_FOLDER}/${folderName}/$fileName.cs3")
+ }
+
+ suspend fun downloadPlugin(
activity: Activity,
pluginUrl: String,
internalName: String,
- repositoryUrl: String
+ repositoryUrl: String,
+ loadPlugin: Boolean
+ ): Boolean {
+ val file = getPluginPath(activity, internalName, repositoryUrl)
+ return downloadPlugin(activity, pluginUrl, internalName, file, loadPlugin)
+ }
+
+ suspend fun downloadPlugin(
+ activity: Activity,
+ pluginUrl: String,
+ internalName: String,
+ file: File,
+ loadPlugin: Boolean
): Boolean {
try {
- val folderName = getPluginSanitizedFileName(repositoryUrl) // Guaranteed unique
- val fileName = getPluginSanitizedFileName(internalName)
- Log.i(TAG, "Downloading plugin: $pluginUrl to $folderName/$fileName")
+ Log.d(TAG, "Downloading plugin: $pluginUrl to ${file.absolutePath}")
// The plugin file needs to be salted with the repository url hash as to allow multiple repositories with the same internal plugin names
- val file = downloadPluginToFile(activity, pluginUrl, fileName, folderName)
- return loadPlugin(
- activity,
- file ?: return false,
- PluginData(internalName, pluginUrl, true, file.absolutePath, PLUGIN_VERSION_NOT_SET)
+ val newFile = downloadPluginToFile(pluginUrl, file) ?: return false
+
+ val data = PluginData(
+ internalName,
+ pluginUrl,
+ true,
+ newFile.absolutePath,
+ PLUGIN_VERSION_NOT_SET
)
+
+ return if (loadPlugin) {
+ unloadPlugin(file.absolutePath)
+ loadPlugin(
+ activity,
+ newFile,
+ data
+ )
+ } else {
+ setPluginData(data)
+ true
+ }
} catch (e: Exception) {
logError(e)
return false
}
}
- /**
- * @param isFilePath will treat the pluginUrl as as the filepath instead of url
- * */
- suspend fun deletePlugin(pluginIdentifier: String, isFilePath: Boolean): Boolean {
- val data =
- (if (isFilePath) (getPluginsLocal() + getPluginsOnline()).firstOrNull { it.filePath == pluginIdentifier }
- else getPluginsOnline().firstOrNull { it.url == pluginIdentifier }) ?: return false
+ suspend fun deletePlugin(file: File): Boolean {
+ val list =
+ (getPluginsLocal() + getPluginsOnline()).filter { it.filePath == file.absolutePath }
return try {
- if (File(data.filePath).delete()) {
- unloadPlugin(data.filePath)
- deletePluginData(data)
+ if (File(file.absolutePath).delete()) {
+ unloadPlugin(file.absolutePath)
+ list.forEach { deletePluginData(it) }
return true
}
false
@@ -413,4 +675,71 @@ object PluginManager {
false
}
}
-}
\ No newline at end of file
+
+ private fun Context.createNotificationChannel() {
+ hasCreatedNotChanel = true
+ // Create the NotificationChannel, but only on API 26+ because
+ // the NotificationChannel class is new and not in the support library
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val name = EXTENSIONS_CHANNEL_NAME //getString(R.string.channel_name)
+ val descriptionText =
+ EXTENSIONS_CHANNEL_DESCRIPT//getString(R.string.channel_description)
+ val importance = NotificationManager.IMPORTANCE_LOW
+ val channel = NotificationChannel(EXTENSIONS_CHANNEL_ID, name, importance).apply {
+ description = descriptionText
+ }
+ // Register the channel with the system
+ val notificationManager: NotificationManager =
+ this.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ notificationManager.createNotificationChannel(channel)
+ }
+ }
+
+ private fun createNotification(
+ context: Context,
+ uitext: UiText,
+ extensions: List
+ ): Notification? {
+ try {
+
+ if (extensions.isEmpty()) return null
+
+ val content = extensions.joinToString(", ")
+// main { // DON'T WANT TO SLOW IT DOWN
+ val builder = NotificationCompat.Builder(context, EXTENSIONS_CHANNEL_ID)
+ .setAutoCancel(false)
+ .setColorized(true)
+ .setOnlyAlertOnce(true)
+ .setSilent(true)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .setColor(context.colorFromAttribute(R.attr.colorPrimary))
+ .setContentTitle(uitext.asString(context))
+ //.setContentTitle(context.getString(title, extensionNames.size))
+ .setSmallIcon(R.drawable.ic_baseline_extension_24)
+ .setStyle(
+ NotificationCompat.BigTextStyle()
+ .bigText(content)
+ )
+ .setContentText(content)
+
+ if (!hasCreatedNotChanel) {
+ context.createNotificationChannel()
+ }
+
+ val notification = builder.build()
+ // notificationId is a unique int for each notification that you must define
+ if (ActivityCompat.checkSelfPermission(
+ context,
+ Manifest.permission.POST_NOTIFICATIONS
+ ) == PackageManager.PERMISSION_GRANTED
+ ) {
+ NotificationManagerCompat.from(context)
+ .notify((System.currentTimeMillis() / 1000).toInt(), notification)
+ }
+ return notification
+ } catch (e: Exception) {
+ logError(e)
+ return null
+ }
+ }
+}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt
index c7e0ff86..c6ec9df7 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt
@@ -2,13 +2,17 @@ package com.lagradost.cloudstream3.plugins
import android.content.Context
import com.fasterxml.jackson.annotation.JsonProperty
+import com.lagradost.cloudstream3.AcraApplication.Companion.context
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
-import com.lagradost.cloudstream3.apmap
+import com.lagradost.cloudstream3.R
+import com.lagradost.cloudstream3.amap
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.mvvm.logError
+import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall
import com.lagradost.cloudstream3.plugins.PluginManager.getPluginSanitizedFileName
+import com.lagradost.cloudstream3.plugins.PluginManager.unloadPlugin
import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY
import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
@@ -69,23 +73,55 @@ object RepositoryManager {
val PREBUILT_REPOSITORIES: Array by lazy {
getKey("PREBUILT_REPOSITORIES") ?: emptyArray()
}
+ private val GH_REGEX = Regex("^https://raw.githubusercontent.com/([A-Za-z0-9-]+)/([A-Za-z0-9_.-]+)/(.*)$")
+
+ /* Convert raw.githubusercontent.com urls to cdn.jsdelivr.net if enabled in settings */
+ fun convertRawGitUrl(url: String): String {
+ if (getKey(context!!.getString(R.string.jsdelivr_proxy_key)) != true) return url
+ val match = GH_REGEX.find(url) ?: return url
+ val (user, repo, rest) = match.destructured
+ return "https://cdn.jsdelivr.net/gh/$user/$repo@$rest"
+ }
+
+ suspend fun parseRepoUrl(url: String): String? {
+ val fixedUrl = url.trim()
+ return if (fixedUrl.contains("^https?://".toRegex())) {
+ fixedUrl
+ } else if (fixedUrl.contains("^(cloudstreamrepo://)|(https://cs\\.repo/\\??)".toRegex())) {
+ fixedUrl.replace("^(cloudstreamrepo://)|(https://cs\\.repo/\\??)".toRegex(), "").let {
+ return@let if (!it.contains("^https?://".toRegex()))
+ "https://${it}"
+ else fixedUrl
+ }
+ } else if (fixedUrl.matches("^[a-zA-Z0-9!_-]+$".toRegex())) {
+ suspendSafeApiCall {
+ app.get("https://cutt.ly/${fixedUrl}", allowRedirects = false).let { it2 ->
+ it2.headers["Location"]?.let { url ->
+ if (url.startsWith("https://cutt.ly/404")) return@suspendSafeApiCall null
+ if (url.removeSuffix("/") == "https://cutt.ly") return@suspendSafeApiCall null
+ return@suspendSafeApiCall url
+ }
+ }
+ }
+ } else null
+ }
suspend fun parseRepository(url: String): Repository? {
return suspendSafeApiCall {
// Take manifestVersion and such into account later
- app.get(url).parsedSafe()
+ app.get(convertRawGitUrl(url)).parsedSafe()
}
}
private suspend fun parsePlugins(pluginUrls: String): List {
// Take manifestVersion and such into account later
return try {
- val response = app.get(pluginUrls)
+ val response = app.get(convertRawGitUrl(pluginUrls))
// Normal parsed function not working?
// return response.parsedSafe()
tryParseJson>(response.text)?.toList() ?: emptyList()
- } catch (e : Exception) {
- logError(e)
+ } catch (t: Throwable) {
+ logError(t)
emptyList()
}
}
@@ -95,7 +131,7 @@ object RepositoryManager {
* */
suspend fun getRepoPlugins(repositoryUrl: String): List>? {
val repo = parseRepository(repositoryUrl) ?: return null
- return repo.pluginLists.apmap { url ->
+ return repo.pluginLists.amap { url ->
parsePlugins(url).map {
repositoryUrl to it
}
@@ -103,29 +139,21 @@ object RepositoryManager {
}
suspend fun downloadPluginToFile(
- context: Context,
pluginUrl: String,
- fileName: String,
- folder: String
+ file: File
): File? {
return suspendSafeApiCall {
- val extensionsDir = File(context.filesDir, ONLINE_PLUGINS_FOLDER)
- if (!extensionsDir.exists())
- extensionsDir.mkdirs()
+ file.mkdirs()
- val newDir = File(extensionsDir, folder)
- newDir.mkdirs()
-
- val newFile = File(newDir, "${fileName}.cs3")
// Overwrite if exists
- if (newFile.exists()) {
- newFile.delete()
+ if (file.exists()) {
+ file.delete()
}
- newFile.createNewFile()
+ file.createNewFile()
- val body = app.get(pluginUrl).okhttpResponse.body
- write(body.byteStream(), newFile.outputStream())
- newFile
+ val body = app.get(convertRawGitUrl(pluginUrl)).okhttpResponse.body
+ write(body.byteStream(), file.outputStream())
+ file
}
}
@@ -160,9 +188,17 @@ object RepositoryManager {
extensionsDir,
getPluginSanitizedFileName(repository.url)
)
- PluginManager.deleteRepositoryData(file.absolutePath)
- file.delete()
+ // Unload all plugins, not using deletePlugin since we
+ // delete all data and files in deleteRepositoryData
+ normalSafeApiCall {
+ file.listFiles { plugin: File ->
+ unloadPlugin(plugin.absolutePath)
+ false
+ }
+ }
+
+ PluginManager.deleteRepositoryData(file.absolutePath)
}
private fun write(stream: InputStream, output: OutputStream) {
@@ -173,4 +209,4 @@ object RepositoryManager {
output.write(dataBuffer, 0, readBytes)
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt
new file mode 100644
index 00000000..d1b702f4
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt
@@ -0,0 +1,108 @@
+package com.lagradost.cloudstream3.plugins
+
+import android.util.Log
+import android.widget.Toast
+import com.lagradost.cloudstream3.AcraApplication.Companion.context
+import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
+import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
+import com.lagradost.cloudstream3.R
+import java.security.MessageDigest
+import com.lagradost.cloudstream3.app
+import com.lagradost.cloudstream3.utils.Coroutines.main
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+
+object VotingApi { // please do not cheat the votes lol
+ private const val LOGKEY = "VotingApi"
+
+ private const val API_DOMAIN = "https://counterapi.com/api"
+
+ private fun transformUrl(url: String): String = // dont touch or all votes get reset
+ MessageDigest
+ .getInstance("SHA-256")
+ .digest("${url}#funny-salt".toByteArray())
+ .fold("") { str, it -> str + "%02x".format(it) }
+
+ suspend fun SitePlugin.getVotes(): Int {
+ return getVotes(url)
+ }
+
+ fun SitePlugin.hasVoted(): Boolean {
+ return hasVoted(url)
+ }
+
+ suspend fun SitePlugin.vote(): Int {
+ return vote(url)
+ }
+
+ fun SitePlugin.canVote(): Boolean {
+ return canVote(this.url)
+ }
+
+ // Plugin url to Int
+ private val votesCache = mutableMapOf()
+
+ private fun getRepository(pluginUrl: String) = pluginUrl
+ .split("/")
+ .drop(2)
+ .take(3)
+ .joinToString("-")
+
+ private suspend fun readVote(pluginUrl: String): Int {
+ val url = "${API_DOMAIN}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}?readOnly=true"
+ Log.d(LOGKEY, "Requesting: $url")
+ return app.get(url).parsedSafe()?.value ?: 0
+ }
+
+ private suspend fun writeVote(pluginUrl: String): Boolean {
+ val url = "${API_DOMAIN}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}"
+ Log.d(LOGKEY, "Requesting: $url")
+ return app.get(url).parsedSafe()?.value != null
+ }
+
+ suspend fun getVotes(pluginUrl: String): Int =
+ votesCache[pluginUrl] ?: readVote(pluginUrl).also {
+ votesCache[pluginUrl] = it
+ }
+
+ fun hasVoted(pluginUrl: String) =
+ getKey("cs3-votes/${transformUrl(pluginUrl)}") ?: false
+
+ fun canVote(pluginUrl: String): Boolean {
+ return PluginManager.urlPlugins.contains(pluginUrl)
+ }
+
+ private val voteLock = Mutex()
+ suspend fun vote(pluginUrl: String): Int {
+ // Prevent multiple requests at the same time.
+ voteLock.withLock {
+ if (!canVote(pluginUrl)) {
+ main {
+ Toast.makeText(context, R.string.extension_install_first, Toast.LENGTH_SHORT)
+ .show()
+ }
+ return getVotes(pluginUrl)
+ }
+
+ if (hasVoted(pluginUrl)) {
+ main {
+ Toast.makeText(context, R.string.already_voted, Toast.LENGTH_SHORT)
+ .show()
+ }
+ return getVotes(pluginUrl)
+ }
+
+
+ if (writeVote(pluginUrl)) {
+ setKey("cs3-votes/${transformUrl(pluginUrl)}", true)
+ votesCache[pluginUrl] = votesCache[pluginUrl]?.plus(1) ?: 1
+ }
+
+ return getVotes(pluginUrl)
+ }
+ }
+
+ private data class Result(
+ val value: Int?
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/services/BackupWorkManager.kt b/app/src/main/java/com/lagradost/cloudstream3/services/BackupWorkManager.kt
new file mode 100644
index 00000000..4ef841f5
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/services/BackupWorkManager.kt
@@ -0,0 +1,96 @@
+package com.lagradost.cloudstream3.services
+
+import android.content.Context
+import androidx.core.app.NotificationCompat
+import androidx.work.Constraints
+import androidx.work.CoroutineWorker
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.ForegroundInfo
+import androidx.work.PeriodicWorkRequest
+import androidx.work.WorkManager
+import androidx.work.WorkerParameters
+import com.lagradost.cloudstream3.R
+import com.lagradost.cloudstream3.utils.AppContextUtils.createNotificationChannel
+import com.lagradost.cloudstream3.utils.BackupUtils
+import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
+import java.util.concurrent.TimeUnit
+
+const val BACKUP_CHANNEL_ID = "cloudstream3.backups"
+const val BACKUP_WORK_NAME = "work_backup"
+const val BACKUP_CHANNEL_NAME = "Backups"
+const val BACKUP_CHANNEL_DESCRIPTION = "Notifications for background backups"
+const val BACKUP_NOTIFICATION_ID = 938712898 // Random unique
+
+class BackupWorkManager(val context: Context, workerParams: WorkerParameters) :
+ CoroutineWorker(context, workerParams) {
+ companion object {
+ fun enqueuePeriodicWork(context: Context?, intervalHours: Long) {
+ if (context == null) return
+
+ if (intervalHours == 0L) {
+ WorkManager.getInstance(context).cancelUniqueWork(BACKUP_WORK_NAME)
+ return
+ }
+
+ val constraints = Constraints.Builder()
+ .setRequiresStorageNotLow(true)
+ .build()
+
+ val periodicSyncDataWork =
+ PeriodicWorkRequest.Builder(
+ BackupWorkManager::class.java,
+ intervalHours,
+ TimeUnit.HOURS
+ )
+ .addTag(BACKUP_WORK_NAME)
+ .setConstraints(constraints)
+ .build()
+
+ WorkManager.getInstance(context).enqueueUniquePeriodicWork(
+ BACKUP_WORK_NAME,
+ ExistingPeriodicWorkPolicy.UPDATE,
+ periodicSyncDataWork
+ )
+
+ // Uncomment below for testing
+
+// val oneTimeBackupWork =
+// OneTimeWorkRequest.Builder(BackupWorkManager::class.java)
+// .addTag(BACKUP_WORK_NAME)
+// .setConstraints(constraints)
+// .build()
+//
+// WorkManager.getInstance(context).enqueue(oneTimeBackupWork)
+ }
+ }
+
+ private val backupNotificationBuilder =
+ NotificationCompat.Builder(context, BACKUP_CHANNEL_ID)
+ .setColorized(true)
+ .setOnlyAlertOnce(true)
+ .setSilent(true)
+ .setAutoCancel(true)
+ .setContentTitle(context.getString(R.string.pref_category_backup))
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+ .setColor(context.colorFromAttribute(R.attr.colorPrimary))
+ .setSmallIcon(R.drawable.ic_cloudstream_monochrome_big)
+
+ override suspend fun doWork(): Result {
+ context.createNotificationChannel(
+ BACKUP_CHANNEL_ID,
+ BACKUP_CHANNEL_NAME,
+ BACKUP_CHANNEL_DESCRIPTION
+ )
+
+ setForeground(
+ ForegroundInfo(
+ BACKUP_NOTIFICATION_ID,
+ backupNotificationBuilder.build()
+ )
+ )
+
+ BackupUtils.backup(context)
+
+ return Result.success()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt b/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt
new file mode 100644
index 00000000..00c74dff
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt
@@ -0,0 +1,235 @@
+package com.lagradost.cloudstream3.services
+
+import android.annotation.SuppressLint
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import androidx.core.app.NotificationCompat
+import androidx.core.net.toUri
+import androidx.work.*
+import com.lagradost.cloudstream3.*
+import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
+import com.lagradost.cloudstream3.R
+import com.lagradost.cloudstream3.mvvm.logError
+import com.lagradost.cloudstream3.plugins.PluginManager
+import com.lagradost.cloudstream3.ui.result.txt
+import com.lagradost.cloudstream3.utils.AppContextUtils.createNotificationChannel
+import com.lagradost.cloudstream3.utils.AppContextUtils.getApiDubstatusSettings
+import com.lagradost.cloudstream3.utils.Coroutines.ioWork
+import com.lagradost.cloudstream3.utils.DataStoreHelper
+import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions
+import com.lagradost.cloudstream3.utils.DataStoreHelper.getDub
+import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
+import com.lagradost.cloudstream3.utils.VideoDownloadManager.getImageBitmapFromUrl
+import kotlinx.coroutines.withTimeoutOrNull
+import java.util.concurrent.TimeUnit
+
+const val SUBSCRIPTION_CHANNEL_ID = "cloudstream3.subscriptions"
+const val SUBSCRIPTION_WORK_NAME = "work_subscription"
+const val SUBSCRIPTION_CHANNEL_NAME = "Subscriptions"
+const val SUBSCRIPTION_CHANNEL_DESCRIPTION = "Notifications for new episodes on subscribed shows"
+const val SUBSCRIPTION_NOTIFICATION_ID = 938712897 // Random unique
+
+class SubscriptionWorkManager(val context: Context, workerParams: WorkerParameters) :
+ CoroutineWorker(context, workerParams) {
+ companion object {
+ fun enqueuePeriodicWork(context: Context?) {
+ if (context == null) return
+
+ val constraints = Constraints.Builder()
+ .setRequiredNetworkType(NetworkType.CONNECTED)
+ .build()
+
+ val periodicSyncDataWork =
+ PeriodicWorkRequest.Builder(SubscriptionWorkManager::class.java, 6, TimeUnit.HOURS)
+ .addTag(SUBSCRIPTION_WORK_NAME)
+ .setConstraints(constraints)
+ .build()
+
+ WorkManager.getInstance(context).enqueueUniquePeriodicWork(
+ SUBSCRIPTION_WORK_NAME,
+ ExistingPeriodicWorkPolicy.KEEP,
+ periodicSyncDataWork
+ )
+
+ // Uncomment below for testing
+
+// val oneTimeSyncDataWork =
+// OneTimeWorkRequest.Builder(SubscriptionWorkManager::class.java)
+// .addTag(SUBSCRIPTION_WORK_NAME)
+// .setConstraints(constraints)
+// .build()
+//
+// WorkManager.getInstance(context).enqueue(oneTimeSyncDataWork)
+ }
+ }
+
+ private val progressNotificationBuilder =
+ NotificationCompat.Builder(context, SUBSCRIPTION_CHANNEL_ID)
+ .setAutoCancel(false)
+ .setColorized(true)
+ .setOnlyAlertOnce(true)
+ .setSilent(true)
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+ .setColor(context.colorFromAttribute(R.attr.colorPrimary))
+ .setContentTitle(context.getString(R.string.subscription_in_progress_notification))
+ .setSmallIcon(R.drawable.quantum_ic_refresh_white_24)
+ .setProgress(0, 0, true)
+
+ private val updateNotificationBuilder =
+ NotificationCompat.Builder(context, SUBSCRIPTION_CHANNEL_ID)
+ .setColorized(true)
+ .setOnlyAlertOnce(true)
+ .setAutoCancel(true)
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+ .setColor(context.colorFromAttribute(R.attr.colorPrimary))
+ .setSmallIcon(R.drawable.ic_cloudstream_monochrome_big)
+
+ private val notificationManager: NotificationManager =
+ context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+
+ private fun updateProgress(max: Int, progress: Int, indeterminate: Boolean) {
+ notificationManager.notify(
+ SUBSCRIPTION_NOTIFICATION_ID, progressNotificationBuilder
+ .setProgress(max, progress, indeterminate)
+ .build()
+ )
+ }
+
+ @SuppressLint("UnspecifiedImmutableFlag")
+ override suspend fun doWork(): Result {
+ try {
+// println("Update subscriptions!")
+ context.createNotificationChannel(
+ SUBSCRIPTION_CHANNEL_ID,
+ SUBSCRIPTION_CHANNEL_NAME,
+ SUBSCRIPTION_CHANNEL_DESCRIPTION
+ )
+
+ setForeground(
+ ForegroundInfo(
+ SUBSCRIPTION_NOTIFICATION_ID,
+ progressNotificationBuilder.build()
+ )
+ )
+
+ val subscriptions = getAllSubscriptions()
+
+ if (subscriptions.isEmpty()) {
+ WorkManager.getInstance(context).cancelWorkById(this.id)
+ return Result.success()
+ }
+
+ val max = subscriptions.size
+ var progress = 0
+
+ updateProgress(max, progress, true)
+
+ // We need all plugins loaded.
+ PluginManager.loadAllOnlinePlugins(context)
+ PluginManager.loadAllLocalPlugins(context, false)
+
+ subscriptions.apmap { savedData ->
+ try {
+ val id = savedData.id ?: return@apmap null
+ val api = getApiFromNameNull(savedData.apiName) ?: return@apmap null
+
+ // Reasonable timeout to prevent having this worker run forever.
+ val response = withTimeoutOrNull(60_000) {
+ api.load(savedData.url) as? EpisodeResponse
+ } ?: return@apmap null
+
+ val dubPreference =
+ getDub(id) ?: if (
+ context.getApiDubstatusSettings().contains(DubStatus.Dubbed)
+ ) {
+ DubStatus.Dubbed
+ } else {
+ DubStatus.Subbed
+ }
+
+ val latestEpisodes = response.getLatestEpisodes()
+ val latestPreferredEpisode = latestEpisodes[dubPreference]
+
+ val (shouldUpdate, latestEpisode) = if (latestPreferredEpisode != null) {
+ val latestSeenEpisode =
+ savedData.lastSeenEpisodeCount[dubPreference] ?: Int.MIN_VALUE
+ val shouldUpdate = latestPreferredEpisode > latestSeenEpisode
+ shouldUpdate to latestPreferredEpisode
+ } else {
+ val latestEpisode = latestEpisodes[DubStatus.None] ?: Int.MIN_VALUE
+ val latestSeenEpisode =
+ savedData.lastSeenEpisodeCount[DubStatus.None] ?: Int.MIN_VALUE
+ val shouldUpdate = latestEpisode > latestSeenEpisode
+ shouldUpdate to latestEpisode
+ }
+
+ DataStoreHelper.updateSubscribedData(
+ id,
+ savedData,
+ response
+ )
+
+ if (shouldUpdate) {
+ val updateHeader = savedData.name
+ val updateDescription = txt(
+ R.string.subscription_episode_released,
+ latestEpisode,
+ savedData.name
+ ).asString(context)
+
+ val intent = Intent(context, MainActivity::class.java).apply {
+ data = savedData.url.toUri()
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
+ }
+
+ val pendingIntent =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ PendingIntent.getActivity(
+ context,
+ 0,
+ intent,
+ PendingIntent.FLAG_IMMUTABLE
+ )
+ } else {
+ PendingIntent.getActivity(context, 0, intent, 0)
+ }
+
+ val poster = ioWork {
+ savedData.posterUrl?.let { url ->
+ context.getImageBitmapFromUrl(
+ url,
+ savedData.posterHeaders
+ )
+ }
+ }
+
+ val updateNotification =
+ updateNotificationBuilder.setContentTitle(updateHeader)
+ .setContentText(updateDescription)
+ .setContentIntent(pendingIntent)
+ .setLargeIcon(poster)
+ .build()
+
+ notificationManager.notify(id, updateNotification)
+ }
+
+ // You can probably get some issues here since this is async but it does not matter much.
+ updateProgress(max, ++progress, false)
+ } catch (t: Throwable) {
+ logError(t)
+ }
+ }
+
+ return Result.success()
+ } catch (t: Throwable) {
+ logError(t)
+ // ye, while this is not correct, but because gods know why android just crashes
+ // and this causes major battery usage as it retries it inf times. This is better, just
+ // in case android decides to be android and fuck us
+ return Result.success()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/services/VideoDownloadService.kt b/app/src/main/java/com/lagradost/cloudstream3/services/VideoDownloadService.kt
index be2fe75b..6151a0ed 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/services/VideoDownloadService.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/services/VideoDownloadService.kt
@@ -1,11 +1,22 @@
package com.lagradost.cloudstream3.services
-
-import android.app.IntentService
+import android.app.Service
import android.content.Intent
+import android.os.IBinder
import com.lagradost.cloudstream3.utils.VideoDownloadManager
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
-class VideoDownloadService : IntentService("VideoDownloadService") {
- override fun onHandleIntent(intent: Intent?) {
+class VideoDownloadService : Service() {
+
+ private val downloadScope = CoroutineScope(Dispatchers.Default)
+
+ override fun onBind(intent: Intent?): IBinder? {
+ return null
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent != null) {
val id = intent.getIntExtra("id", -1)
val type = intent.getStringExtra("type")
@@ -14,10 +25,36 @@ class VideoDownloadService : IntentService("VideoDownloadService") {
"resume" -> VideoDownloadManager.DownloadActionType.Resume
"pause" -> VideoDownloadManager.DownloadActionType.Pause
"stop" -> VideoDownloadManager.DownloadActionType.Stop
- else -> return
+ else -> return START_NOT_STICKY
+ }
+
+ downloadScope.launch {
+ VideoDownloadManager.downloadEvent.invoke(Pair(id, state))
}
- VideoDownloadManager.downloadEvent.invoke(Pair(id, state))
}
}
+
+ return START_NOT_STICKY
}
-}
\ No newline at end of file
+
+ override fun onDestroy() {
+ downloadScope.coroutineContext.cancel()
+ super.onDestroy()
+ }
+}
+// override fun onHandleIntent(intent: Intent?) {
+// if (intent != null) {
+// val id = intent.getIntExtra("id", -1)
+// val type = intent.getStringExtra("type")
+// if (id != -1 && type != null) {
+// val state = when (type) {
+// "resume" -> VideoDownloadManager.DownloadActionType.Resume
+// "pause" -> VideoDownloadManager.DownloadActionType.Pause
+// "stop" -> VideoDownloadManager.DownloadActionType.Stop
+// else -> return
+// }
+// VideoDownloadManager.downloadEvent.invoke(Pair(id, state))
+// }
+// }
+// }
+//}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubProvider.kt b/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubProvider.kt
index 77a1b0b5..df64caab 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubProvider.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubProvider.kt
@@ -1,11 +1,23 @@
package com.lagradost.cloudstream3.subtitles
import androidx.annotation.WorkerThread
+import androidx.core.net.toUri
+import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit
+import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch
import com.lagradost.cloudstream3.syncproviders.AuthAPI
+import com.lagradost.cloudstream3.ui.player.SubtitleOrigin
+import okio.BufferedSource
+import okio.buffer
+import okio.sink
+import okio.source
+import java.io.File
+import java.util.zip.ZipInputStream
interface AbstractSubProvider {
+ val idPrefix: String
+
@WorkerThread
suspend fun search(query: SubtitleSearch): List? {
throw NotImplementedError()
@@ -15,6 +27,98 @@ interface AbstractSubProvider {
suspend fun load(data: SubtitleEntity): String? {
throw NotImplementedError()
}
+
+ @WorkerThread
+ suspend fun SubtitleResource.getResources(data: SubtitleEntity) {
+ this.addUrl(load(data))
+ }
+
+ @WorkerThread
+ suspend fun getResource(data: SubtitleEntity): SubtitleResource {
+ return SubtitleResource().apply {
+ this.getResources(data)
+ }
+ }
+}
+
+/**
+ * A builder for subtitle files.
+ * @see addUrl
+ * @see addFile
+ */
+class SubtitleResource {
+ fun downloadFile(source: BufferedSource): File {
+ val file = File.createTempFile("temp-subtitle", ".tmp").apply {
+ deleteFileOnExit(this)
+ }
+ val sink = file.sink().buffer()
+ sink.writeAll(source)
+ sink.close()
+ source.close()
+
+ return file
+ }
+
+ private fun unzip(file: File): List> {
+ val entries = mutableListOf>()
+
+ ZipInputStream(file.inputStream()).use { zipInputStream ->
+ var zipEntry = zipInputStream.nextEntry
+
+ while (zipEntry != null) {
+ val tempFile = File.createTempFile("unzipped-subtitle", ".tmp").apply {
+ deleteFileOnExit(this)
+ }
+ entries.add(zipEntry.name to tempFile)
+
+ tempFile.sink().buffer().use { buffer ->
+ buffer.writeAll(zipInputStream.source())
+ }
+
+ zipEntry = zipInputStream.nextEntry
+ }
+ }
+ return entries
+ }
+
+ data class SingleSubtitleResource(
+ val name: String?,
+ val url: String,
+ val origin: SubtitleOrigin
+ )
+
+ private var resources: MutableList = mutableListOf()
+
+ fun getSubtitles(): List {
+ return resources.toList()
+ }
+
+ fun addUrl(url: String?, name: String? = null) {
+ if (url == null) return
+ this.resources.add(
+ SingleSubtitleResource(name, url, SubtitleOrigin.URL)
+ )
+ }
+
+ fun addFile(file: File, name: String? = null) {
+ this.resources.add(
+ SingleSubtitleResource(name, file.toUri().toString(), SubtitleOrigin.DOWNLOADED_FILE)
+ )
+ deleteFileOnExit(file)
+ }
+
+ suspend fun addZipUrl(
+ url: String,
+ nameGenerator: (String, File) -> String? = { _, _ -> null }
+ ) {
+ val source = app.get(url).okhttpResponse.body.source()
+ val zip = downloadFile(source)
+ val realFiles = unzip(zip)
+ zip.deleteRecursively()
+ realFiles.forEach { (name, subtitleFile) ->
+ addFile(subtitleFile, nameGenerator(name, subtitleFile))
+ }
+ }
}
interface AbstractSubApi : AbstractSubProvider, AuthAPI
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubtitleEntities.kt b/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubtitleEntities.kt
index e7e5b857..685b499b 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubtitleEntities.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubtitleEntities.kt
@@ -13,13 +13,17 @@ class AbstractSubtitleEntities {
var epNumber: Int? = null,
var seasonNumber: Int? = null,
var year: Int? = null,
- var isHearingImpaired: Boolean = false
+ var isHearingImpaired: Boolean = false,
+ var headers: Map = emptyMap()
)
data class SubtitleSearch(
var query: String = "",
- var imdb: Long? = null,
var lang: String? = null,
+ var imdbId: String? = null,
+ var tmdbId: Int? = null,
+ var malId: Int? = null,
+ var aniListId: Int? = null,
var epNumber: Int? = null,
var seasonNumber: Int? = null,
var year: Int? = null
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt
index 2bc39b54..2e14c3c4 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt
@@ -3,52 +3,75 @@ package com.lagradost.cloudstream3.syncproviders
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKeys
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
+import com.lagradost.cloudstream3.LoadResponse
import com.lagradost.cloudstream3.syncproviders.providers.*
import java.util.concurrent.TimeUnit
abstract class AccountManager(private val defIndex: Int) : AuthAPI {
companion object {
- val malApi = MALApi(0)
- val aniListApi = AniListApi(0)
+ val malApi = MALApi(0).also { api ->
+ LoadResponse.Companion.malIdPrefix = api.idPrefix
+ }
+ val aniListApi = AniListApi(0).also { api ->
+ LoadResponse.Companion.aniListIdPrefix = api.idPrefix
+ }
+ val simklApi = SimklApi(0).also { api ->
+ LoadResponse.Companion.simklIdPrefix = api.idPrefix
+ }
val openSubtitlesApi = OpenSubtitlesApi(0)
- val indexSubtitlesApi = IndexSubtitleApi()
+ val addic7ed = Addic7ed()
+ val subDlApi = SubDlApi(0)
+ val localListApi = LocalList()
+ val subSourceApi = SubSourceApi()
// used to login via app intent
val OAuth2Apis
get() = listOf(
- malApi, aniListApi
+ malApi, aniListApi, simklApi
)
// this needs init with context and can be accessed in settings
val accountManagers
get() = listOf(
- malApi, aniListApi, openSubtitlesApi, //nginxApi
+ malApi, aniListApi, openSubtitlesApi, subDlApi, simklApi //nginxApi
)
// used for active syncing
val SyncApis
get() = listOf(
- SyncRepo(malApi), SyncRepo(aniListApi)
+ SyncRepo(malApi), SyncRepo(aniListApi), SyncRepo(localListApi), SyncRepo(simklApi)
)
val inAppAuths
- get() = listOf(openSubtitlesApi)//, nginxApi)
+ get() = listOf(
+ openSubtitlesApi,
+ subDlApi
+ )//, nginxApi)
val subtitleProviders
get() = listOf(
openSubtitlesApi,
-// indexSubtitlesApi // they got anti scraping measures in place :(
+ addic7ed,
+ subDlApi,
+ subSourceApi
)
- const val appString = "cloudstreamapp"
- const val appStringRepo = "cloudstreamrepo"
+ const val APP_STRING = "cloudstreamapp"
+ const val APP_STRING_REPO = "cloudstreamrepo"
+ const val APP_STRING_PLAYER = "cloudstreamplayer"
+
+ // Instantly start the search given a query
+ const val APP_STRING_SEARCH = "cloudstreamsearch"
+
+ // Instantly resume watching a show
+ const val APP_STRING_RESUME_WATCHING = "cloudstreamcontinuewatching"
val unixTime: Long
get() = System.currentTimeMillis() / 1000L
val unixTimeMs: Long
get() = System.currentTimeMillis()
- const val maxStale = 60 * 10
+ const val MAX_STALE = 60 * 10
fun secondsToReadable(seconds: Int, completedValue: String): String {
var secondsLong = seconds.toLong()
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/OAuth2API.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/OAuth2API.kt
index 0f882f3b..3d0bb940 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/OAuth2API.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/OAuth2API.kt
@@ -1,9 +1,27 @@
package com.lagradost.cloudstream3.syncproviders
+import androidx.fragment.app.FragmentActivity
+
interface OAuth2API : AuthAPI {
val key: String
val redirectUrl: String
+ val supportDeviceAuth: Boolean
suspend fun handleRedirect(url: String) : Boolean
- fun authenticate()
+ fun authenticate(activity: FragmentActivity?)
+ suspend fun getDevicePin() : PinAuthData? {
+ return null
+ }
+
+ suspend fun handleDeviceAuth(pinAuthData: PinAuthData) : Boolean {
+ return false
+ }
+
+ data class PinAuthData(
+ val deviceCode: String,
+ val userCode: String,
+ val verificationUrl: String,
+ val expiresIn: Int,
+ val interval: Int,
+ )
}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt
deleted file mode 100644
index 5aa56a02..00000000
--- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt
+++ /dev/null
@@ -1,79 +0,0 @@
-package com.lagradost.cloudstream3.syncproviders
-
-import com.lagradost.cloudstream3.*
-
-interface SyncAPI : OAuth2API {
- val mainUrl: String
-
- /**
- -1 -> None
- 0 -> Watching
- 1 -> Completed
- 2 -> OnHold
- 3 -> Dropped
- 4 -> PlanToWatch
- 5 -> ReWatching
- */
- suspend fun score(id: String, status: SyncStatus): Boolean
-
- suspend fun getStatus(id: String): SyncStatus?
-
- suspend fun getResult(id: String): SyncResult?
-
- suspend fun search(name: String): List?
-
- fun getIdFromUrl(url : String) : String
-
- data class SyncSearchResult(
- override val name: String,
- override val apiName: String,
- var syncId: String,
- override val url: String,
- override var posterUrl: String?,
- override var type: TvType? = null,
- override var quality: SearchQuality? = null,
- override var posterHeaders: Map? = null,
- override var id: Int? = null,
- ) : SearchResponse
-
- data class SyncStatus(
- val status: Int,
- /** 1-10 */
- val score: Int?,
- val watchedEpisodes: Int?,
- var isFavorite: Boolean? = null,
- var maxEpisodes : Int? = null,
- )
-
- data class SyncResult(
- /**Used to verify*/
- var id: String,
-
- var totalEpisodes: Int? = null,
-
- var title: String? = null,
- /**1-1000*/
- var publicScore: Int? = null,
- /**In minutes*/
- var duration: Int? = null,
- var synopsis: String? = null,
- var airStatus: ShowStatus? = null,
- var nextAiring: NextAiring? = null,
- var studio: List? = null,
- var genres: List? = null,
- var synonyms: List? = null,
- var trailers: List? = null,
- var isAdult : Boolean? = null,
- var posterUrl: String? = null,
- var backgroundPosterUrl : String? = null,
-
- /** In unixtime */
- var startDate: Long? = null,
- /** In unixtime */
- var endDate: Long? = null,
- var recommendations: List? = null,
- var nextSeason: SyncSearchResult? = null,
- var prevSeason: SyncSearchResult? = null,
- var actors: List? = null,
- )
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncApi.kt
new file mode 100644
index 00000000..dcb8bbea
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncApi.kt
@@ -0,0 +1,170 @@
+package com.lagradost.cloudstream3.syncproviders
+
+import com.lagradost.cloudstream3.*
+import com.lagradost.cloudstream3.ui.SyncWatchType
+import com.lagradost.cloudstream3.ui.library.ListSorting
+import com.lagradost.cloudstream3.ui.result.UiText
+import me.xdrop.fuzzywuzzy.FuzzySearch
+import java.util.Date
+
+interface SyncAPI : OAuth2API {
+ /**
+ * Set this to true if the user updates something on the list like watch status or score
+ **/
+ var requireLibraryRefresh: Boolean
+ val mainUrl: String
+
+ /**
+ * Allows certain providers to open pages from
+ * library links.
+ **/
+ val syncIdName: SyncIdName
+
+ /**
+ -1 -> None
+ 0 -> Watching
+ 1 -> Completed
+ 2 -> OnHold
+ 3 -> Dropped
+ 4 -> PlanToWatch
+ 5 -> ReWatching
+ */
+ suspend fun score(id: String, status: AbstractSyncStatus): Boolean
+
+ suspend fun getStatus(id: String): AbstractSyncStatus?
+
+ suspend fun getResult(id: String): SyncResult?
+
+ suspend fun search(name: String): List?
+
+ suspend fun getPersonalLibrary(): LibraryMetadata?
+
+ fun getIdFromUrl(url: String): String
+
+ data class SyncSearchResult(
+ override val name: String,
+ override val apiName: String,
+ var syncId: String,
+ override val url: String,
+ override var posterUrl: String?,
+ override var type: TvType? = null,
+ override var quality: SearchQuality? = null,
+ override var posterHeaders: Map? = null,
+ override var id: Int? = null,
+ ) : SearchResponse
+
+ abstract class AbstractSyncStatus {
+ abstract var status: SyncWatchType
+
+ /** 1-10 */
+ abstract var score: Int?
+ abstract var watchedEpisodes: Int?
+ abstract var isFavorite: Boolean?
+ abstract var maxEpisodes: Int?
+ }
+
+
+ data class SyncStatus(
+ override var status: SyncWatchType,
+ /** 1-10 */
+ override var score: Int?,
+ override var watchedEpisodes: Int?,
+ override var isFavorite: Boolean? = null,
+ override var maxEpisodes: Int? = null,
+ ) : AbstractSyncStatus()
+
+ data class SyncResult(
+ /**Used to verify*/
+ var id: String,
+
+ var totalEpisodes: Int? = null,
+
+ var title: String? = null,
+ /**1-1000*/
+ var publicScore: Int? = null,
+ /**In minutes*/
+ var duration: Int? = null,
+ var synopsis: String? = null,
+ var airStatus: ShowStatus? = null,
+ var nextAiring: NextAiring? = null,
+ var studio: List? = null,
+ var genres: List? = null,
+ var synonyms: List? = null,
+ var trailers: List? = null,
+ var isAdult: Boolean? = null,
+ var posterUrl: String? = null,
+ var backgroundPosterUrl: String? = null,
+
+ /** In unixtime */
+ var startDate: Long? = null,
+ /** In unixtime */
+ var endDate: Long? = null,
+ var recommendations: List? = null,
+ var nextSeason: SyncSearchResult? = null,
+ var prevSeason: SyncSearchResult? = null,
+ var actors: List? = null,
+ )
+
+
+ data class Page(
+ val title: UiText, var items: List
+ ) {
+ fun sort(method: ListSorting?, query: String? = null) {
+ items = when (method) {
+ ListSorting.Query ->
+ if (query != null) {
+ items.sortedBy {
+ -FuzzySearch.partialRatio(
+ query.lowercase(), it.name.lowercase()
+ )
+ }
+ } else items
+ ListSorting.RatingHigh -> items.sortedBy { -(it.personalRating ?: 0) }
+ ListSorting.RatingLow -> items.sortedBy { (it.personalRating ?: 0) }
+ ListSorting.AlphabeticalA -> items.sortedBy { it.name }
+ ListSorting.AlphabeticalZ -> items.sortedBy { it.name }.reversed()
+ ListSorting.UpdatedNew -> items.sortedBy { it.lastUpdatedUnixTime?.times(-1) }
+ ListSorting.UpdatedOld -> items.sortedBy { it.lastUpdatedUnixTime }
+ ListSorting.ReleaseDateNew -> items.sortedByDescending { it.releaseDate }
+ ListSorting.ReleaseDateOld -> items.sortedBy { it.releaseDate }
+ else -> items
+ }
+ }
+ }
+
+ data class LibraryMetadata(
+ val allLibraryLists: List,
+ val supportedListSorting: Set
+ )
+
+ data class LibraryList(
+ val name: UiText,
+ val items: List
+ )
+
+ data class LibraryItem(
+ override val name: String,
+ override val url: String,
+ /**
+ * Unique unchanging string used for data storage.
+ * This should be the actual id when you change scores and status
+ * since score changes from library might get added in the future.
+ **/
+ val syncId: String,
+ val episodesCompleted: Int?,
+ val episodesTotal: Int?,
+ /** Out of 100 */
+ val personalRating: Int?,
+ val lastUpdatedUnixTime: Long?,
+ override val apiName: String,
+ override var type: TvType?,
+ override var posterUrl: String?,
+ override var posterHeaders: Map?,
+ override var quality: SearchQuality?,
+ val releaseDate: Date?,
+ override var id: Int? = null,
+ val plot : String? = null,
+ val rating: Int? = null,
+ val tags: List? = null
+ ) : SearchResponse
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt
index b621e81a..9363cb6f 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt
@@ -11,26 +11,38 @@ class SyncRepo(private val repo: SyncAPI) {
val icon = repo.icon
val mainUrl = repo.mainUrl
val requiresLogin = repo.requiresLogin
+ val syncIdName = repo.syncIdName
+ var requireLibraryRefresh: Boolean
+ get() = repo.requireLibraryRefresh
+ set(value) {
+ repo.requireLibraryRefresh = value
+ }
- suspend fun score(id: String, status: SyncAPI.SyncStatus): Resource {
+ suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Resource {
return safeApiCall { repo.score(id, status) }
}
- suspend fun getStatus(id : String) : Resource {
+ suspend fun getStatus(id: String): Resource {
return safeApiCall { repo.getStatus(id) ?: throw ErrorLoadingException("No data") }
}
- suspend fun getResult(id : String) : Resource {
+ suspend fun getResult(id: String): Resource {
return safeApiCall { repo.getResult(id) ?: throw ErrorLoadingException("No data") }
}
- suspend fun search(query : String) : Resource> {
+ suspend fun search(query: String): Resource> {
return safeApiCall { repo.search(query) ?: throw ErrorLoadingException() }
}
- fun hasAccount() : Boolean {
+ suspend fun getPersonalLibrary(): Resource {
+ return safeApiCall { repo.getPersonalLibrary() ?: throw ErrorLoadingException() }
+ }
+
+ fun hasAccount(): Boolean {
return normalSafeApiCall { repo.loginInfo() != null } ?: false
}
- fun getIdFromUrl(url : String) : String = repo.getIdFromUrl(url)
+ fun getIdFromUrl(url: String): String? = normalSafeApiCall {
+ repo.getIdFromUrl(url)
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Addic7ed.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Addic7ed.kt
new file mode 100644
index 00000000..db467639
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Addic7ed.kt
@@ -0,0 +1,108 @@
+package com.lagradost.cloudstream3.syncproviders.providers
+
+import com.lagradost.cloudstream3.TvType
+import com.lagradost.cloudstream3.app
+import com.lagradost.cloudstream3.subtitles.AbstractSubApi
+import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
+import com.lagradost.cloudstream3.utils.SubtitleHelper
+
+class Addic7ed : AbstractSubApi {
+ override val name = "Addic7ed"
+ override val idPrefix = "addic7ed"
+ override val requiresLogin = false
+ override val icon: Nothing? = null
+ override val createAccountUrl: Nothing? = null
+
+ override fun loginInfo(): Nothing? = null
+
+ override fun logOut() {}
+
+ companion object {
+ const val HOST = "https://www.addic7ed.com"
+ const val TAG = "ADDIC7ED"
+ }
+
+ private fun fixUrl(url: String): String {
+ return if (url.startsWith("/")) HOST + url
+ else if (!url.startsWith("http")) "$HOST/$url"
+ else url
+
+ }
+
+ override suspend fun search(query: AbstractSubtitleEntities.SubtitleSearch): List {
+ val lang = query.lang
+ val queryLang = SubtitleHelper.fromTwoLettersToLanguage(lang.toString())
+ val queryText = query.query.trim()
+ val epNum = query.epNumber ?: 0
+ val seasonNum = query.seasonNumber ?: 0
+ val yearNum = query.year ?: 0
+
+ fun cleanResources(
+ results: MutableList,
+ name: String,
+ link: String,
+ headers: Map,
+ isHearingImpaired: Boolean
+ ) {
+ results.add(
+ AbstractSubtitleEntities.SubtitleEntity(
+ idPrefix = idPrefix,
+ name = name,
+ lang = queryLang.toString(),
+ data = link,
+ source = this.name,
+ type = if (seasonNum > 0) TvType.TvSeries else TvType.Movie,
+ epNumber = epNum,
+ seasonNumber = seasonNum,
+ year = yearNum,
+ headers = headers,
+ isHearingImpaired = isHearingImpaired
+ )
+ )
+ }
+
+ val title = queryText.substringBefore("(").trim()
+ val url = "$HOST/search.php?search=${title}&Submit=Search"
+ val hostDocument = app.get(url).document
+ var searchResult = ""
+ if (!hostDocument.select("span:contains($title)").isNullOrEmpty()) searchResult = url
+ else if (!hostDocument.select("table.tabel")
+ .isNullOrEmpty()
+ ) searchResult = hostDocument.select("a:contains($title)").attr("href").toString()
+ else {
+ val show =
+ hostDocument.selectFirst("#sl button")?.attr("onmouseup")?.substringAfter("(")
+ ?.substringBefore(",")
+ val doc = app.get(
+ "$HOST/ajax_loadShow.php?show=$show&season=$seasonNum&langs=&hd=undefined&hi=undefined",
+ referer = "$HOST/"
+ ).document
+ doc.select("#season tr:contains($queryLang)").mapNotNull { node ->
+ if (node.selectFirst("td")?.text()
+ ?.toIntOrNull() == seasonNum && node.select("td:eq(1)")
+ .text()
+ .toIntOrNull() == epNum
+ ) searchResult = fixUrl(node.select("a").attr("href"))
+ }
+ }
+ val results = mutableListOf()
+ val document = app.get(
+ url = fixUrl(searchResult),
+ ).document
+
+ document.select(".tabel95 .tabel95 tr:contains($queryLang)").mapNotNull { node ->
+ val name = if (seasonNum > 0) "${document.select(".titulo").text().replace("Subtitle","").trim()}${
+ node.parent()!!.select(".NewsTitle").text().substringAfter("Version").substringBefore(", Duration")
+ }" else "${document.select(".titulo").text().replace("Subtitle","").trim()}${node.parent()!!.select(".NewsTitle").text().substringAfter("Version").substringBefore(", Duration")}"
+ val link = fixUrl(node.select("a.buttonDownload").attr("href"))
+ val isHearingImpaired =
+ !node.parent()!!.select("tr:last-child [title=\"Hearing Impaired\"]").isNullOrEmpty()
+ cleanResources(results, name, link, mapOf("referer" to "$HOST/"), isHearingImpaired)
+ }
+ return results
+ }
+
+ override suspend fun load(data: AbstractSubtitleEntities.SubtitleEntity): String {
+ return data.data
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt
index 606fee97..6112c7db 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt
@@ -1,36 +1,44 @@
package com.lagradost.cloudstream3.syncproviders.providers
+import androidx.annotation.StringRes
+import androidx.fragment.app.FragmentActivity
import com.fasterxml.jackson.annotation.JsonProperty
-import com.fasterxml.jackson.databind.DeserializationFeature
-import com.fasterxml.jackson.databind.json.JsonMapper
-import com.fasterxml.jackson.module.kotlin.KotlinModule
import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
-import com.lagradost.cloudstream3.AcraApplication.Companion.getKeys
import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.mvvm.logError
+import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall
import com.lagradost.cloudstream3.syncproviders.AccountManager
import com.lagradost.cloudstream3.syncproviders.AuthAPI
import com.lagradost.cloudstream3.syncproviders.SyncAPI
+import com.lagradost.cloudstream3.syncproviders.SyncIdName
+import com.lagradost.cloudstream3.ui.SyncWatchType
+import com.lagradost.cloudstream3.ui.library.ListSorting
+import com.lagradost.cloudstream3.ui.result.txt
+import com.lagradost.cloudstream3.utils.AppContextUtils.splitQuery
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
-import com.lagradost.cloudstream3.utils.AppUtils.splitQuery
import com.lagradost.cloudstream3.utils.AppUtils.toJson
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.DataStore.toKotlinObject
+import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear
import java.net.URL
-import java.util.*
+import java.net.URLEncoder
+import java.util.Locale
class AniListApi(index: Int) : AccountManager(index), SyncAPI {
override var name = "AniList"
override val key = "6871"
override val redirectUrl = "anilistlogin"
override val idPrefix = "anilist"
+ override var requireLibraryRefresh = true
+ override val supportDeviceAuth = false
override var mainUrl = "https://anilist.co"
override val icon = R.drawable.ic_anilist_icon
override val requiresLogin = false
override val createAccountUrl = "$mainUrl/signup"
+ override val syncIdName = SyncIdName.Anilist
override fun loginInfo(): AuthAPI.LoginInfo? {
// context.getUser(true)?.
@@ -45,17 +53,18 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
}
override fun logOut() {
+ requireLibraryRefresh = true
removeAccountKeys()
}
- override fun authenticate() {
+ override fun authenticate(activity: FragmentActivity?) {
val request = "https://anilist.co/api/v2/oauth/authorize?client_id=$key&response_type=token"
- openBrowser(request)
+ openBrowser(request, activity)
}
override suspend fun handleRedirect(url: String): Boolean {
val sanitizer =
- splitQuery(URL(url.replace(appString, "https").replace("/#", "?"))) // FIX ERROR
+ splitQuery(URL(url.replace(APP_STRING, "https").replace("/#", "?"))) // FIX ERROR
val token = sanitizer["access_token"]!!
val expiresIn = sanitizer["expires_in"]!!
@@ -64,8 +73,8 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
switchToNewAccount()
setKey(accountId, ANILIST_UNIXTIME_KEY, endTime)
setKey(accountId, ANILIST_TOKEN_KEY, token)
- setKey(ANILIST_SHOULD_UPDATE_LIST, true)
val user = getUser()
+ requireLibraryRefresh = true
return user != null
}
@@ -79,7 +88,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
override suspend fun search(name: String): List? {
val data = searchShows(name) ?: return null
- return data.data?.Page?.media?.map {
+ return data.data?.page?.media?.map {
SyncAPI.SyncSearchResult(
it.title.romaji ?: return null,
this.name,
@@ -93,7 +102,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
override suspend fun getResult(id: String): SyncAPI.SyncResult {
val internalId = (Regex("anilist\\.co/anime/(\\d*)").find(id)?.groupValues?.getOrNull(1)
?: id).toIntOrNull() ?: throw ErrorLoadingException("Invalid internalId")
- val season = getSeason(internalId).data.Media
+ val season = getSeason(internalId).data.media
return SyncAPI.SyncResult(
season.id.toString(),
@@ -140,7 +149,8 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
this.name,
recMedia.id?.toString() ?: return@mapNotNull null,
getUrlFromId(recMedia.id),
- recMedia.coverImage?.large ?: recMedia.coverImage?.medium
+ recMedia.coverImage?.extraLarge ?: recMedia.coverImage?.large
+ ?: recMedia.coverImage?.medium
)
},
trailers = when (season.trailer?.site?.lowercase()?.trim()) {
@@ -151,26 +161,28 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
)
}
- override suspend fun getStatus(id: String): SyncAPI.SyncStatus? {
+ override suspend fun getStatus(id: String): SyncAPI.AbstractSyncStatus? {
val internalId = id.toIntOrNull() ?: return null
val data = getDataAboutId(internalId) ?: return null
return SyncAPI.SyncStatus(
score = data.score,
watchedEpisodes = data.progress,
- status = data.type?.value ?: return null,
+ status = SyncWatchType.fromInternalId(data.type?.value ?: return null),
isFavorite = data.isFavourite,
maxEpisodes = data.episodes,
)
}
- override suspend fun score(id: String, status: SyncAPI.SyncStatus): Boolean {
+ override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean {
return postDataAboutId(
id.toIntOrNull() ?: return false,
- fromIntToAnimeStatus(status.status),
+ fromIntToAnimeStatus(status.status.internalId),
status.score,
status.watchedEpisodes
- )
+ ).also {
+ requireLibraryRefresh = requireLibraryRefresh || it
+ }
}
companion object {
@@ -181,7 +193,6 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
const val ANILIST_TOKEN_KEY: String = "anilist_token" // anilist token for api
const val ANILIST_USER_KEY: String = "anilist_user" // user data like profile
const val ANILIST_CACHED_LIST: String = "anilist_cached_list"
- const val ANILIST_SHOULD_UPDATE_LIST: String = "anilist_should_update_list"
private fun fixName(name: String): String {
return name.lowercase(Locale.ROOT).replace(" ", "")
@@ -219,7 +230,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
romaji
}
idMal
- coverImage { medium large }
+ coverImage { medium large extraLarge }
averageScore
}
}
@@ -232,7 +243,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
format
id
idMal
- coverImage { medium large }
+ coverImage { medium large extraLarge }
averageScore
title {
english
@@ -291,16 +302,14 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
//println("NAME $name NEW NAME ${name.replace(blackListRegex, "")}")
val shows = searchShows(name.replace(blackListRegex, ""))
- shows?.data?.Page?.media?.find {
- malId ?: "NONE" == it.idMal.toString()
+ shows?.data?.page?.media?.find {
+ (malId ?: "NONE") == it.idMal.toString()
}?.let { return it }
val filtered =
- shows?.data?.Page?.media?.filter {
- (
- it.startDate.year ?: year.toString() == year.toString()
- || year == null
- )
+ shows?.data?.page?.media?.filter {
+ (((it.startDate.year ?: year.toString()) == year.toString()
+ || year == null))
}
filtered?.forEach {
it.title.romaji?.let { romaji ->
@@ -312,14 +321,14 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
}
// Changing names of these will show up in UI
- enum class AniListStatusType(var value: Int) {
- Watching(0),
- Completed(1),
- Paused(2),
- Dropped(3),
- Planning(4),
- ReWatching(5),
- None(-1)
+ enum class AniListStatusType(var value: Int, @StringRes val stringRes: Int) {
+ Watching(0, R.string.type_watching),
+ Completed(1, R.string.type_completed),
+ Paused(2, R.string.type_on_hold),
+ Dropped(3, R.string.type_dropped),
+ Planning(4, R.string.type_plan_to_watch),
+ ReWatching(5, R.string.type_re_watching),
+ None(-1, R.string.none)
}
fun fromIntToAnimeStatus(inp: Int): AniListStatusType {//= AniListStatusType.values().first { it.value == inp }
@@ -335,7 +344,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
}
}
- fun convertAnilistStringToStatus(string: String): AniListStatusType {
+ fun convertAniListStringToStatus(string: String): AniListStatusType {
return fromIntToAnimeStatus(aniListStatusString.indexOf(string))
}
@@ -488,7 +497,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
val data = postApi(q, true)
val d = parseJson(data ?: return null)
- val main = d.data?.Media
+ val main = d.data?.media
if (main?.mediaListEntry != null) {
return AniListTitleHolder(
title = main.title,
@@ -521,19 +530,27 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
}
private suspend fun postApi(q: String, cache: Boolean = false): String? {
- return if (!checkToken()) {
- app.post(
- "https://graphql.anilist.co/",
- headers = mapOf(
- "Authorization" to "Bearer " + (getAuth() ?: return null),
- if (cache) "Cache-Control" to "max-stale=$maxStale" else "Cache-Control" to "no-cache"
- ),
- cacheTime = 0,
- data = mapOf("query" to q),//(if (vars == null) mapOf("query" to q) else mapOf("query" to q, "variables" to vars))
- timeout = 5 // REASONABLE TIMEOUT
- ).text.replace("\\/", "/")
- } else {
- null
+ return suspendSafeApiCall {
+ if (!checkToken()) {
+ app.post(
+ "https://graphql.anilist.co/",
+ headers = mapOf(
+ "Authorization" to "Bearer " + (getAuth()
+ ?: return@suspendSafeApiCall null),
+ if (cache) "Cache-Control" to "max-stale=$MAX_STALE" else "Cache-Control" to "no-cache"
+ ),
+ cacheTime = 0,
+ data = mapOf(
+ "query" to URLEncoder.encode(
+ q,
+ "UTF-8"
+ )
+ ), //(if (vars == null) mapOf("query" to q) else mapOf("query" to q, "variables" to vars))
+ timeout = 5 // REASONABLE TIMEOUT
+ ).text.replace("\\/", "/")
+ } else {
+ null
+ }
}
}
@@ -568,7 +585,8 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
data class CoverImage(
@JsonProperty("medium") val medium: String?,
- @JsonProperty("large") val large: String?
+ @JsonProperty("large") val large: String?,
+ @JsonProperty("extraLarge") val extraLarge: String?
)
data class Media(
@@ -580,7 +598,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
//@JsonProperty("source") val source: String,
@JsonProperty("episodes") val episodes: Int,
@JsonProperty("title") val title: Title,
- //@JsonProperty("description") val description: String,
+ @JsonProperty("description") val description: String?,
@JsonProperty("coverImage") val coverImage: CoverImage,
@JsonProperty("synonyms") val synonyms: List,
@JsonProperty("nextAiringEpisode") val nextAiringEpisode: SeasonNextAiringEpisode?,
@@ -595,7 +613,31 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
@JsonProperty("score") val score: Int,
@JsonProperty("private") val private: Boolean,
@JsonProperty("media") val media: Media
- )
+ ) {
+ fun toLibraryItem(): SyncAPI.LibraryItem {
+ return SyncAPI.LibraryItem(
+ // English title first
+ this.media.title.english ?: this.media.title.romaji
+ ?: this.media.synonyms.firstOrNull()
+ ?: "",
+ "https://anilist.co/anime/${this.media.id}/",
+ this.media.id.toString(),
+ this.progress,
+ this.media.episodes,
+ this.score,
+ this.updatedAt.toLong(),
+ "AniList",
+ TvType.Anime,
+ this.media.coverImage.extraLarge ?: this.media.coverImage.large
+ ?: this.media.coverImage.medium,
+ null,
+ null,
+ this.media.seasonYear.toYear(),
+ null,
+ plot = this.media.description,
+ )
+ }
+ }
data class Lists(
@JsonProperty("status") val status: String?,
@@ -607,43 +649,64 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
)
data class Data(
- @JsonProperty("MediaListCollection") val MediaListCollection: MediaListCollection
+ @JsonProperty("MediaListCollection") val mediaListCollection: MediaListCollection
)
- fun getAnilistListCached(): Array? {
+ private fun getAniListListCached(): Array? {
return getKey(ANILIST_CACHED_LIST) as? Array
}
- suspend fun getAnilistAnimeListSmart(): Array? {
+ private suspend fun getAniListAnimeListSmart(): Array? {
if (getAuth() == null) return null
if (checkToken()) return null
- return if (getKey(ANILIST_SHOULD_UPDATE_LIST, true) == true) {
- val list = getFullAnilistList()?.data?.MediaListCollection?.lists?.toTypedArray()
+ return if (requireLibraryRefresh) {
+ val list = getFullAniListList()?.data?.mediaListCollection?.lists?.toTypedArray()
if (list != null) {
setKey(ANILIST_CACHED_LIST, list)
- setKey(ANILIST_SHOULD_UPDATE_LIST, false)
}
list
} else {
- getAnilistListCached()
+ getAniListListCached()
}
}
- private suspend fun getFullAnilistList(): FullAnilistList? {
- var userID: Int? = null
- /** WARNING ASSUMES ONE USER! **/
- getKeys(ANILIST_USER_KEY)?.forEach { key ->
- getKey(key, null)?.let {
- userID = it.id
- }
- }
+ override suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata {
+ val list = getAniListAnimeListSmart()?.groupBy {
+ convertAniListStringToStatus(it.status ?: "").stringRes
+ }?.mapValues { group ->
+ group.value.map { it.entries.map { entry -> entry.toLibraryItem() } }.flatten()
+ } ?: emptyMap()
- val fixedUserID = userID ?: return null
+ // To fill empty lists when AniList does not return them
+ val baseMap =
+ AniListStatusType.entries.filter { it.value >= 0 }.associate {
+ it.stringRes to emptyList()
+ }
+
+ return SyncAPI.LibraryMetadata(
+ (baseMap + list).map { SyncAPI.LibraryList(txt(it.key), it.value) },
+ setOf(
+ ListSorting.AlphabeticalA,
+ ListSorting.AlphabeticalZ,
+ ListSorting.UpdatedNew,
+ ListSorting.UpdatedOld,
+ ListSorting.ReleaseDateNew,
+ ListSorting.ReleaseDateOld,
+ ListSorting.RatingHigh,
+ ListSorting.RatingLow,
+ )
+ )
+ }
+
+ private suspend fun getFullAniListList(): FullAnilistList? {
+ /** WARNING ASSUMES ONE USER! **/
+
+ val userID = getKey(accountId, ANILIST_USER_KEY)?.id ?: return null
val mediaType = "ANIME"
val query = """
- query (${'$'}userID: Int = $fixedUserID, ${'$'}MEDIA: MediaType = $mediaType) {
+ query (${'$'}userID: Int = $userID, ${'$'}MEDIA: MediaType = $mediaType) {
MediaListCollection (userId: ${'$'}userID, type: ${'$'}MEDIA) {
lists {
status
@@ -654,7 +717,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
startedAt { year month day }
updatedAt
progress
- score
+ score (format: POINT_100)
private
media
{
@@ -670,7 +733,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
english
romaji
}
- coverImage { medium }
+ coverImage { extraLarge large medium }
synonyms
nextAiringEpisode {
timeUntilAiring
@@ -703,6 +766,11 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
return data != ""
}
+ /** Used to query a saved MediaItem on the list to get the id for removal */
+ data class MediaListItemRoot(@JsonProperty("data") val data: MediaListItem? = null)
+ data class MediaListItem(@JsonProperty("MediaList") val mediaList: MediaListId? = null)
+ data class MediaListId(@JsonProperty("id") val id: Long? = null)
+
private suspend fun postDataAboutId(
id: Int,
type: AniListStatusType,
@@ -710,19 +778,43 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
progress: Int?
): Boolean {
val q =
- """mutation (${'$'}id: Int = $id, ${'$'}status: MediaListStatus = ${
- aniListStatusString[maxOf(
- 0,
- type.value
- )]
- }, ${if (score != null) "${'$'}scoreRaw: Int = ${score * 10}" else ""} , ${if (progress != null) "${'$'}progress: Int = $progress" else ""}) {
- SaveMediaListEntry (mediaId: ${'$'}id, status: ${'$'}status, scoreRaw: ${'$'}scoreRaw, progress: ${'$'}progress) {
- id
- status
- progress
- score
- }
+ // Delete item if status type is None
+ if (type == AniListStatusType.None) {
+ val userID = getKey(accountId, ANILIST_USER_KEY)?.id ?: return false
+ // Get list ID for deletion
+ val idQuery = """
+ query MediaList(${'$'}userId: Int = $userID, ${'$'}mediaId: Int = $id) {
+ MediaList(userId: ${'$'}userId, mediaId: ${'$'}mediaId) {
+ id
+ }
+ }
+ """
+ val response = postApi(idQuery)
+ val listId =
+ tryParseJson(response)?.data?.mediaList?.id ?: return false
+ """
+ mutation(${'$'}id: Int = $listId) {
+ DeleteMediaListEntry(id: ${'$'}id) {
+ deleted
+ }
+ }
+ """
+ } else {
+ """mutation (${'$'}id: Int = $id, ${'$'}status: MediaListStatus = ${
+ aniListStatusString[maxOf(
+ 0,
+ type.value
+ )]
+ }, ${if (score != null) "${'$'}scoreRaw: Int = ${score * 10}" else ""} , ${if (progress != null) "${'$'}progress: Int = $progress" else ""}) {
+ SaveMediaListEntry (mediaId: ${'$'}id, status: ${'$'}status, scoreRaw: ${'$'}scoreRaw, progress: ${'$'}progress) {
+ id
+ status
+ progress
+ score
+ }
}"""
+ }
+
val data = postApi(q)
return data != ""
}
@@ -748,7 +840,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
val data = postApi(q)
if (data.isNullOrBlank()) return null
val userData = parseJson