Initial commit
This commit is contained in:
commit
21bfbfb139
520 changed files with 47819 additions and 0 deletions
4
app/.gitignore
vendored
Normal file
4
app/.gitignore
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
/build
|
||||
/debug
|
||||
/debug/output-metadata.json
|
||||
/release
|
119
app/build.gradle
Normal file
119
app/build.gradle
Normal file
|
@ -0,0 +1,119 @@
|
|||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'com.google.gms.google-services'
|
||||
id 'com.google.firebase.crashlytics'
|
||||
id 'kotlin-android'
|
||||
id 'kotlinx-serialization'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
id 'com.google.devtools.ksp'
|
||||
|
||||
}
|
||||
|
||||
def gitCommitHash = providers.exec {
|
||||
commandLine("git", "rev-parse", "--verify", "--short", "HEAD")
|
||||
}.standardOutput.asText.get().trim()
|
||||
|
||||
android {
|
||||
compileSdk 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId "ani.dantotsu"
|
||||
minSdk 23
|
||||
targetSdk 34
|
||||
versionCode ((System.currentTimeMillis() / 60000).toInteger())
|
||||
versionName "0.0.1"
|
||||
signingConfig signingConfigs.debug
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
//applicationIdSuffix ".beta"
|
||||
debuggable true
|
||||
versionNameSuffix "." + gitCommitHash
|
||||
}
|
||||
release {
|
||||
debuggable false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = '17'
|
||||
freeCompilerArgs = ["-Xcontext-receivers", "-Xmulti-platform"]
|
||||
}
|
||||
namespace 'ani.dantotsu'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Core
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation 'androidx.browser:browser:1.6.0'
|
||||
implementation 'androidx.core:core-ktx:1.12.0'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.6.1'
|
||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
|
||||
implementation 'androidx.multidex:multidex:2.0.1'
|
||||
implementation "androidx.work:work-runtime-ktx:2.8.1"
|
||||
implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||
|
||||
implementation 'com.github.Blatzar:NiceHttp:0.4.3'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0'
|
||||
|
||||
// Glide
|
||||
ext.glide_version = '4.16.0'
|
||||
api "com.github.bumptech.glide:glide:$glide_version"
|
||||
implementation "com.github.bumptech.glide:glide:$glide_version"
|
||||
ksp "com.github.bumptech.glide:ksp:$glide_version"
|
||||
implementation "com.github.bumptech.glide:okhttp3-integration:$glide_version"
|
||||
implementation 'jp.wasabeef:glide-transformations:4.3.0'
|
||||
|
||||
// FireBase
|
||||
implementation platform('com.google.firebase:firebase-bom:32.2.3')
|
||||
implementation 'com.google.firebase:firebase-analytics-ktx:21.3.0'
|
||||
implementation 'com.google.firebase:firebase-crashlytics-ktx:18.4.3'
|
||||
|
||||
// Exoplayer
|
||||
ext.exo_version = '1.1.1'
|
||||
implementation "androidx.media3:media3-exoplayer:$exo_version"
|
||||
implementation "androidx.media3:media3-ui:$exo_version"
|
||||
implementation "androidx.media3:media3-exoplayer-hls:$exo_version"
|
||||
implementation "androidx.media3:media3-exoplayer-dash:$exo_version"
|
||||
implementation "androidx.media3:media3-datasource-okhttp:$exo_version"
|
||||
implementation "androidx.media3:media3-session:$exo_version"
|
||||
|
||||
// UI
|
||||
implementation 'com.google.android.material:material:1.10.0'
|
||||
implementation 'nl.joery.animatedbottombar:library:1.1.0'
|
||||
implementation 'io.noties.markwon:core:4.6.2'
|
||||
implementation 'com.flaviofaria:kenburnsview:1.0.7'
|
||||
implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
|
||||
implementation 'com.alexvasilkov:gesture-views:2.8.3'
|
||||
implementation 'com.github.VipulOG:ebook-reader:0.1.6'
|
||||
|
||||
// Aniyomi
|
||||
implementation 'io.reactivex:rxjava:1.3.8'
|
||||
implementation 'io.reactivex:rxandroid:1.2.1'
|
||||
implementation 'ru.beryukhov:flowreactivenetwork:1.0.4'
|
||||
implementation 'ca.gosyer:voyager-navigator:1.0.0-rc07'
|
||||
implementation 'com.squareup.logcat:logcat:0.1'
|
||||
implementation 'com.github.inorichi.injekt:injekt-core:65b0440'
|
||||
implementation 'com.squareup.okhttp3:logging-interceptor:5.0.0-alpha.11'
|
||||
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.11'
|
||||
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps'
|
||||
implementation 'com.squareup.okio:okio:3.3.0'
|
||||
implementation 'ch.acra:acra-http:5.9.7'
|
||||
implementation 'org.jsoup:jsoup:1.15.4'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json-okio:1.5.0'
|
||||
implementation 'com.jakewharton.rxrelay:rxrelay:1.2.0'
|
||||
implementation 'com.github.tachiyomiorg:unifile:17bec43'
|
||||
implementation 'com.github.gpanther:java-nat-sort:natural-comparator-1.1'
|
||||
implementation 'androidx.preference:preference-ktx:1.2.0'
|
||||
|
||||
}
|
61
app/proguard-rules.pro
vendored
Normal file
61
app/proguard-rules.pro
vendored
Normal file
|
@ -0,0 +1,61 @@
|
|||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.kts.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
|
||||
# Keep `Companion` object fields of serializable classes.
|
||||
# This avoids serializer lookup through `getDeclaredClasses` as done for named companion objects.
|
||||
-if @kotlinx.serialization.Serializable class **
|
||||
-keepclassmembers class <1> {
|
||||
static <1>$Companion Companion;
|
||||
}
|
||||
|
||||
# Keep `serializer()` on companion objects (both default and named) of serializable classes.
|
||||
-if @kotlinx.serialization.Serializable class ** {
|
||||
static **$* *;
|
||||
}
|
||||
-keepclassmembers class <2>$<3> {
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
|
||||
# Keep `INSTANCE.serializer()` of serializable objects.
|
||||
-if @kotlinx.serialization.Serializable class ** {
|
||||
public static ** INSTANCE;
|
||||
}
|
||||
-keepclassmembers class <1> {
|
||||
public static <1> INSTANCE;
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
|
||||
# @Serializable and @Polymorphic are used at runtime for polymorphic serialization.
|
||||
-keepattributes RuntimeVisibleAnnotations,AnnotationDefault
|
||||
|
||||
# Serializer for classes with named companion objects are retrieved using `getDeclaredClasses`.
|
||||
# If you have any, uncomment and replace classes with those containing named companion objects.
|
||||
#-keepattributes InnerClasses # Needed for `getDeclaredClasses`.
|
||||
#-if @kotlinx.serialization.Serializable class
|
||||
#com.example.myapplication.HasNamedCompanion, # <-- List serializable classes with named companions.
|
||||
#com.example.myapplication.HasNamedCompanion2
|
||||
#{
|
||||
# static **$* *;
|
||||
#}
|
||||
#-keepnames class <1>$$serializer { # -keepnames suffices; class is kept when serializer() is kept.
|
||||
# static <1>$$serializer INSTANCE;
|
||||
#}
|
4
app/src/debug/res/values/strings.xml
Normal file
4
app/src/debug/res/values/strings.xml
Normal file
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Dantotsu α</string>
|
||||
</resources>
|
250
app/src/main/AndroidManifest.xml
Normal file
250
app/src/main/AndroidManifest.xml
Normal file
|
@ -0,0 +1,250 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="29"
|
||||
tools:ignore="ScopedStorage" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
|
||||
<!-- For background jobs -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
<!-- For managing extensions -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
|
||||
<!-- To view extension packages in API 30+ -->
|
||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||
tools:ignore="QueryAllPackagesPermission" />
|
||||
|
||||
<uses-permission android:name="android.permission.READ_APP_SPECIFIC_LOCALES"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
|
||||
<queries>
|
||||
<package android:name="idm.internet.download.manager.plus" />
|
||||
<package android:name="idm.internet.download.manager" />
|
||||
<package android:name="idm.internet.download.manager.adm.lite" />
|
||||
<package android:name="com.dv.adm" />
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:name=".App"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:largeHeap="true"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Dantotsu"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:ignore="AllowBackup"
|
||||
tools:targetApi="m">
|
||||
<activity
|
||||
android:name="ani.dantotsu.media.novel.novelreader.NovelReaderActivity"
|
||||
android:configChanges="orientation|screenSize"
|
||||
android:exported="true" >
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
|
||||
<data android:mimeType="application/epub+zip" />
|
||||
<data android:mimeType="application/x-mobipocket-ebook" />
|
||||
<data android:mimeType="application/vnd.amazon.ebook" />
|
||||
<data android:mimeType="application/fb2+zip" />
|
||||
<data android:mimeType="application/vnd.comicbook+zip" />
|
||||
|
||||
<data android:pathPattern=".*\\.epub" />
|
||||
<data android:pathPattern=".*\\.mobi" />
|
||||
<data android:pathPattern=".*\\.kf8" />
|
||||
<data android:pathPattern=".*\\.fb2" />
|
||||
<data android:pathPattern=".*\\.cbz" />
|
||||
|
||||
<data android:scheme="content" />
|
||||
<data android:scheme="file" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity android:name=".settings.FAQActivity" />
|
||||
<activity android:name=".settings.ReaderSettingsActivity" />
|
||||
<activity android:name=".settings.UserInterfaceSettingsActivity" />
|
||||
<activity android:name=".settings.PlayerSettingsActivity" />
|
||||
<activity
|
||||
android:name=".settings.SettingsActivity"
|
||||
android:parentActivityName=".MainActivity" />
|
||||
<activity
|
||||
android:name=".settings.ExtensionsActivity"
|
||||
android:parentActivityName=".MainActivity" />
|
||||
<activity
|
||||
android:name=".others.imagesearch.ImageSearchActivity"
|
||||
android:parentActivityName=".MainActivity" />
|
||||
<activity
|
||||
android:name=".media.SearchActivity"
|
||||
android:parentActivityName=".MainActivity" />
|
||||
<activity android:name=".media.StudioActivity" />
|
||||
<activity android:name=".media.AuthorActivity" />
|
||||
<activity
|
||||
android:name=".media.CalendarActivity"
|
||||
android:parentActivityName=".MainActivity" />
|
||||
<activity android:name="ani.dantotsu.media.user.ListActivity" />
|
||||
<activity
|
||||
android:name="ani.dantotsu.media.manga.mangareader.MangaReaderActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="true"
|
||||
android:label="@string/manga"
|
||||
android:launchMode="singleTask" />
|
||||
<activity android:name=".media.GenreActivity" />
|
||||
<activity
|
||||
android:name=".media.MediaDetailsActivity"
|
||||
android:parentActivityName=".MainActivity"
|
||||
android:theme="@style/Theme.Dantotsu.NeverCutout" />
|
||||
<activity android:name=".media.CharacterDetailsActivity" />
|
||||
<activity android:name=".home.NoInternet" />
|
||||
<activity
|
||||
android:name="ani.dantotsu.media.anime.ExoplayerView"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="true"
|
||||
android:label="@string/video"
|
||||
android:launchMode="singleTask"
|
||||
android:supportsPictureInPicture="true"
|
||||
tools:targetApi="n" />
|
||||
<activity
|
||||
android:name="ani.dantotsu.connections.anilist.Login"
|
||||
android:configChanges="orientation|screenSize|layoutDirection"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask">
|
||||
<intent-filter android:label="Anilist Login for Dantotsu">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:host="anilist"
|
||||
android:scheme="dantotsu" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name="ani.dantotsu.connections.mal.Login"
|
||||
android:configChanges="orientation|screenSize|layoutDirection"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask">
|
||||
<intent-filter android:label="Myanimelist Login for Dantotsu">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:host="mal"
|
||||
android:scheme="dantotsu" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity android:name="ani.dantotsu.connections.discord.Login"
|
||||
android:configChanges="orientation|screenSize|layoutDirection"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask">
|
||||
<intent-filter android:label="Discord Login for Dantotsu">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="dantotsu"/>
|
||||
<data android:scheme="http" />
|
||||
<data android:scheme="https" />
|
||||
<data android:host="discord.dantotsu.com"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name="ani.dantotsu.connections.anilist.UrlMedia"
|
||||
android:configChanges="orientation|screenSize|layoutDirection"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask">
|
||||
<intent-filter android:label="@string/read_on_dantotsu">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="http" />
|
||||
<data android:scheme="https" />
|
||||
<data android:host="anilist.co" />
|
||||
<data android:host="myanimelist.net" />
|
||||
<data android:pathPrefix="/manga" />
|
||||
</intent-filter>
|
||||
<intent-filter android:label="@string/watch_on_dantotsu">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="http" />
|
||||
<data android:scheme="https" />
|
||||
<data android:host="anilist.co" />
|
||||
<data android:host="myanimelist.net" />
|
||||
<data android:pathPrefix="/anime" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="orientation|screenSize|layoutDirection"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<receiver
|
||||
android:name=".subcriptions.AlarmReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="Aani.dantotsu.ACTION_ALARM"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<meta-data
|
||||
android:name="preloaded_fonts"
|
||||
android:resource="@array/preloaded_fonts" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.provider"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/provider_paths" />
|
||||
</provider>
|
||||
|
||||
<service android:name=".download.video.MyDownloadService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="androidx.media3.exoplayer.downloadService.action.RESTART"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
</intent-filter>
|
||||
</service>
|
||||
<service android:name=".aniyomi.anime.util.AnimeExtensionInstallService"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
87
app/src/main/java/ani/dantotsu/App.kt
Normal file
87
app/src/main/java/ani/dantotsu/App.kt
Normal file
|
@ -0,0 +1,87 @@
|
|||
package ani.dantotsu
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import androidx.multidex.MultiDex
|
||||
import androidx.multidex.MultiDexApplication
|
||||
import ani.dantotsu.aniyomi.anime.custom.AppModule
|
||||
import ani.dantotsu.aniyomi.anime.custom.PreferenceModule
|
||||
import ani.dantotsu.aniyomi.data.Notifications
|
||||
import ani.dantotsu.aniyomi.util.logcat
|
||||
import ani.dantotsu.others.DisabledReports
|
||||
import com.google.firebase.crashlytics.ktx.crashlytics
|
||||
import com.google.firebase.ktx.Firebase
|
||||
import logcat.AndroidLogcatLogger
|
||||
import logcat.LogPriority
|
||||
import logcat.LogcatLogger
|
||||
import uy.kohesive.injekt.Injekt
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
class App : MultiDexApplication() {
|
||||
override fun attachBaseContext(base: Context?) {
|
||||
super.attachBaseContext(base)
|
||||
MultiDex.install(this)
|
||||
}
|
||||
|
||||
init {
|
||||
instance = this
|
||||
}
|
||||
|
||||
val mFTActivityLifecycleCallbacks = FTActivityLifecycleCallbacks()
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
registerActivityLifecycleCallbacks(mFTActivityLifecycleCallbacks)
|
||||
|
||||
Firebase.crashlytics.setCrashlyticsCollectionEnabled(!DisabledReports)
|
||||
initializeNetwork(baseContext)
|
||||
|
||||
Injekt.importModule(AppModule(this))
|
||||
Injekt.importModule(PreferenceModule(this))
|
||||
|
||||
setupNotificationChannels()
|
||||
if (!LogcatLogger.isInstalled) {
|
||||
LogcatLogger.install(AndroidLogcatLogger(LogPriority.VERBOSE))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun setupNotificationChannels() {
|
||||
try {
|
||||
Notifications.createChannels(this)
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR, e) { "Failed to modify notification channels" }
|
||||
}
|
||||
}
|
||||
|
||||
inner class FTActivityLifecycleCallbacks : ActivityLifecycleCallbacks {
|
||||
var currentActivity: Activity? = null
|
||||
override fun onActivityCreated(p0: Activity, p1: Bundle?) {}
|
||||
override fun onActivityStarted(p0: Activity) {
|
||||
currentActivity = p0
|
||||
}
|
||||
|
||||
override fun onActivityResumed(p0: Activity) {
|
||||
currentActivity = p0
|
||||
}
|
||||
|
||||
override fun onActivityPaused(p0: Activity) {}
|
||||
override fun onActivityStopped(p0: Activity) {}
|
||||
override fun onActivitySaveInstanceState(p0: Activity, p1: Bundle) {}
|
||||
override fun onActivityDestroyed(p0: Activity) {}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private var instance: App? = null
|
||||
var context : Context? = null
|
||||
fun currentContext(): Context? {
|
||||
return instance?.mFTActivityLifecycleCallbacks?.currentActivity ?: context
|
||||
}
|
||||
|
||||
fun currentActivity(): Activity? {
|
||||
return instance?.mFTActivityLifecycleCallbacks?.currentActivity
|
||||
}
|
||||
}
|
||||
}
|
873
app/src/main/java/ani/dantotsu/Functions.kt
Normal file
873
app/src/main/java/ani/dantotsu/Functions.kt
Normal file
|
@ -0,0 +1,873 @@
|
|||
package ani.dantotsu
|
||||
|
||||
import android.animation.ObjectAnimator
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.app.DatePickerDialog
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.content.res.Resources.getSystem
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.media.MediaScannerConnection
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities.*
|
||||
import android.net.Uri
|
||||
import android.os.*
|
||||
import android.provider.Settings
|
||||
import android.telephony.TelephonyManager
|
||||
import android.text.InputFilter
|
||||
import android.text.Spanned
|
||||
import android.util.AttributeSet
|
||||
import android.view.*
|
||||
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
import android.view.animation.*
|
||||
import android.widget.*
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.content.ContextCompat.getSystemService
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.math.MathUtils.clamp
|
||||
import androidx.core.view.*
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import ani.dantotsu.BuildConfig.APPLICATION_ID
|
||||
import ani.dantotsu.connections.anilist.Genre
|
||||
import ani.dantotsu.connections.anilist.api.FuzzyDate
|
||||
import ani.dantotsu.databinding.ItemCountDownBinding
|
||||
import ani.dantotsu.media.Media
|
||||
import ani.dantotsu.parsers.ShowResponse
|
||||
import ani.dantotsu.settings.UserInterfaceSettings
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.model.GlideUrl
|
||||
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions.withCrossFade
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
import com.google.android.material.bottomnavigation.BottomNavigationView
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
import com.google.android.material.internal.ViewUtils
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.coroutines.*
|
||||
import nl.joery.animatedbottombar.AnimatedBottomBar
|
||||
import java.io.*
|
||||
import java.lang.Runnable
|
||||
import java.lang.reflect.Field
|
||||
import java.util.*
|
||||
import kotlin.math.*
|
||||
|
||||
|
||||
var statusBarHeight = 0
|
||||
var navBarHeight = 0
|
||||
val Int.dp: Float get() = (this / getSystem().displayMetrics.density)
|
||||
val Float.px: Int get() = (this * getSystem().displayMetrics.density).toInt()
|
||||
|
||||
lateinit var bottomBar: AnimatedBottomBar
|
||||
var selectedOption = 1
|
||||
|
||||
object Refresh {
|
||||
fun all() {
|
||||
for (i in activity) {
|
||||
activity[i.key]!!.postValue(true)
|
||||
}
|
||||
}
|
||||
|
||||
val activity = mutableMapOf<Int, MutableLiveData<Boolean>>()
|
||||
}
|
||||
|
||||
fun currContext(): Context? {
|
||||
return App.currentContext()
|
||||
}
|
||||
|
||||
fun currActivity(): Activity? {
|
||||
return App.currentActivity()
|
||||
}
|
||||
|
||||
var loadMedia: Int? = null
|
||||
var loadIsMAL = false
|
||||
|
||||
fun logger(e: Any?, print: Boolean = true) {
|
||||
if (print)
|
||||
println(e)
|
||||
}
|
||||
|
||||
fun saveData(fileName: String, data: Any?, context: Context? = null) {
|
||||
tryWith {
|
||||
val a = context ?: currContext()
|
||||
if (a != null) {
|
||||
val fos: FileOutputStream = a.openFileOutput(fileName, Context.MODE_PRIVATE)
|
||||
val os = ObjectOutputStream(fos)
|
||||
os.writeObject(data)
|
||||
os.close()
|
||||
fos.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun <T> loadData(fileName: String, context: Context? = null, toast: Boolean = true): T? {
|
||||
val a = context ?: currContext()
|
||||
try {
|
||||
if (a?.fileList() != null)
|
||||
if (fileName in a.fileList()) {
|
||||
val fileIS: FileInputStream = a.openFileInput(fileName)
|
||||
val objIS = ObjectInputStream(fileIS)
|
||||
val data = objIS.readObject() as T
|
||||
objIS.close()
|
||||
fileIS.close()
|
||||
return data
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (toast) snackString(a?.getString(R.string.error_loading_data, fileName))
|
||||
e.printStackTrace()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun initActivity(a: Activity) {
|
||||
val window = a.window
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
val uiSettings = loadData<UserInterfaceSettings>("ui_settings", toast = false) ?: UserInterfaceSettings().apply {
|
||||
saveData("ui_settings", this)
|
||||
}
|
||||
uiSettings.darkMode.apply {
|
||||
AppCompatDelegate.setDefaultNightMode(
|
||||
when (this) {
|
||||
true -> AppCompatDelegate.MODE_NIGHT_YES
|
||||
false -> AppCompatDelegate.MODE_NIGHT_NO
|
||||
else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
|
||||
}
|
||||
)
|
||||
}
|
||||
if (uiSettings.immersiveMode) {
|
||||
if (navBarHeight == 0) {
|
||||
ViewCompat.getRootWindowInsets(window.decorView.findViewById(android.R.id.content))?.apply {
|
||||
navBarHeight = this.getInsets(WindowInsetsCompat.Type.systemBars()).bottom
|
||||
}
|
||||
}
|
||||
a.hideStatusBar()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && statusBarHeight == 0 && a.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT) {
|
||||
window.decorView.rootWindowInsets?.displayCutout?.apply {
|
||||
if (boundingRects.size > 0) {
|
||||
statusBarHeight = min(boundingRects[0].width(), boundingRects[0].height())
|
||||
}
|
||||
}
|
||||
}
|
||||
} else
|
||||
if (statusBarHeight == 0) {
|
||||
val windowInsets = ViewCompat.getRootWindowInsets(window.decorView.findViewById(android.R.id.content))
|
||||
if (windowInsets != null) {
|
||||
val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
statusBarHeight = insets.top
|
||||
navBarHeight = insets.bottom
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
fun Activity.hideSystemBars() {
|
||||
window.decorView.systemUiVisibility = (
|
||||
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
||||
or View.SYSTEM_UI_FLAG_FULLSCREEN
|
||||
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||||
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
fun Activity.hideStatusBar() {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
|
||||
}
|
||||
|
||||
open class BottomSheetDialogFragment : BottomSheetDialogFragment() {
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
if (this.resources.configuration.orientation != Configuration.ORIENTATION_PORTRAIT) {
|
||||
val behavior = BottomSheetBehavior.from(requireView().parent as View)
|
||||
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
}
|
||||
}
|
||||
|
||||
override fun show(manager: FragmentManager, tag: String?) {
|
||||
val ft = manager.beginTransaction()
|
||||
ft.add(this, tag)
|
||||
ft.commitAllowingStateLoss()
|
||||
}
|
||||
}
|
||||
|
||||
fun isOnline(context: Context): Boolean {
|
||||
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
return tryWith {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
val cap = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)
|
||||
return@tryWith if (cap != null) {
|
||||
when {
|
||||
cap.hasTransport(TRANSPORT_BLUETOOTH) ||
|
||||
cap.hasTransport(TRANSPORT_CELLULAR) ||
|
||||
cap.hasTransport(TRANSPORT_ETHERNET) ||
|
||||
cap.hasTransport(TRANSPORT_LOWPAN) ||
|
||||
cap.hasTransport(TRANSPORT_USB) ||
|
||||
cap.hasTransport(TRANSPORT_VPN) ||
|
||||
cap.hasTransport(TRANSPORT_WIFI) ||
|
||||
cap.hasTransport(TRANSPORT_WIFI_AWARE) -> true
|
||||
|
||||
else -> false
|
||||
}
|
||||
} else false
|
||||
} else true
|
||||
} ?: false
|
||||
}
|
||||
|
||||
fun startMainActivity(activity: Activity, bundle: Bundle? = null) {
|
||||
activity.finishAffinity()
|
||||
activity.startActivity(
|
||||
Intent(
|
||||
activity,
|
||||
MainActivity::class.java
|
||||
).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
if (bundle != null) putExtras(bundle)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
class DatePickerFragment(activity: Activity, var date: FuzzyDate = FuzzyDate().getToday()) : DialogFragment(),
|
||||
DatePickerDialog.OnDateSetListener {
|
||||
var dialog: DatePickerDialog
|
||||
|
||||
init {
|
||||
val c = Calendar.getInstance()
|
||||
val year = date.year ?: c.get(Calendar.YEAR)
|
||||
val month = if (date.month != null) date.month!! - 1 else c.get(Calendar.MONTH)
|
||||
val day = date.day ?: c.get(Calendar.DAY_OF_MONTH)
|
||||
dialog = DatePickerDialog(activity, this, year, month, day)
|
||||
dialog.setButton(
|
||||
DialogInterface.BUTTON_NEUTRAL,
|
||||
activity.getString(R.string.remove)
|
||||
) { dialog, which ->
|
||||
if (which == DialogInterface.BUTTON_NEUTRAL) {
|
||||
date = FuzzyDate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDateSet(view: DatePicker, year: Int, month: Int, day: Int) {
|
||||
date = FuzzyDate(year, month + 1, day)
|
||||
}
|
||||
}
|
||||
|
||||
class InputFilterMinMax(private val min: Double, private val max: Double, private val status: AutoCompleteTextView? = null) :
|
||||
InputFilter {
|
||||
override fun filter(source: CharSequence, start: Int, end: Int, dest: Spanned, dstart: Int, dend: Int): CharSequence? {
|
||||
try {
|
||||
val input = (dest.toString() + source.toString()).toDouble()
|
||||
if (isInRange(min, max, input)) return null
|
||||
} catch (nfe: NumberFormatException) {
|
||||
logger(nfe.stackTraceToString())
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
private fun isInRange(a: Double, b: Double, c: Double): Boolean {
|
||||
val statusStrings = currContext()!!.resources.getStringArray(R.array.status_manga)[2]
|
||||
|
||||
if (c == b) {
|
||||
status?.setText(statusStrings, false)
|
||||
status?.parent?.requestLayout()
|
||||
}
|
||||
return if (b > a) c in a..b else c in b..a
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ZoomOutPageTransformer(private val uiSettings: UserInterfaceSettings) : ViewPager2.PageTransformer {
|
||||
override fun transformPage(view: View, position: Float) {
|
||||
if (position == 0.0f && uiSettings.layoutAnimations) {
|
||||
setAnimation(view.context, view, uiSettings, 300, floatArrayOf(1.3f, 1f, 1.3f, 1f), 0.5f to 0f)
|
||||
ObjectAnimator.ofFloat(view, "alpha", 0f, 1.0f).setDuration((200 * uiSettings.animationSpeed).toLong()).start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setAnimation(
|
||||
context: Context,
|
||||
viewToAnimate: View,
|
||||
uiSettings: UserInterfaceSettings,
|
||||
duration: Long = 150,
|
||||
list: FloatArray = floatArrayOf(0.0f, 1.0f, 0.0f, 1.0f),
|
||||
pivot: Pair<Float, Float> = 0.5f to 0.5f
|
||||
) {
|
||||
if (uiSettings.layoutAnimations) {
|
||||
val anim = ScaleAnimation(
|
||||
list[0],
|
||||
list[1],
|
||||
list[2],
|
||||
list[3],
|
||||
Animation.RELATIVE_TO_SELF,
|
||||
pivot.first,
|
||||
Animation.RELATIVE_TO_SELF,
|
||||
pivot.second
|
||||
)
|
||||
anim.duration = (duration * uiSettings.animationSpeed).toLong()
|
||||
anim.setInterpolator(context, R.anim.over_shoot)
|
||||
viewToAnimate.startAnimation(anim)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class FadingEdgeRecyclerView : RecyclerView {
|
||||
|
||||
constructor(context: Context) : super(context)
|
||||
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
|
||||
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
|
||||
|
||||
override fun isPaddingOffsetRequired(): Boolean {
|
||||
return !clipToPadding
|
||||
}
|
||||
|
||||
override fun getLeftPaddingOffset(): Int {
|
||||
return if (clipToPadding) 0 else -paddingLeft
|
||||
}
|
||||
|
||||
override fun getTopPaddingOffset(): Int {
|
||||
return if (clipToPadding) 0 else -paddingTop
|
||||
}
|
||||
|
||||
override fun getRightPaddingOffset(): Int {
|
||||
return if (clipToPadding) 0 else paddingRight
|
||||
}
|
||||
|
||||
override fun getBottomPaddingOffset(): Int {
|
||||
return if (clipToPadding) 0 else paddingBottom
|
||||
}
|
||||
}
|
||||
|
||||
fun levenshtein(lhs: CharSequence, rhs: CharSequence): Int {
|
||||
if (lhs == rhs) {
|
||||
return 0
|
||||
}
|
||||
if (lhs.isEmpty()) {
|
||||
return rhs.length
|
||||
}
|
||||
if (rhs.isEmpty()) {
|
||||
return lhs.length
|
||||
}
|
||||
|
||||
val lhsLength = lhs.length + 1
|
||||
val rhsLength = rhs.length + 1
|
||||
|
||||
var cost = Array(lhsLength) { it }
|
||||
var newCost = Array(lhsLength) { 0 }
|
||||
|
||||
for (i in 1 until rhsLength) {
|
||||
newCost[0] = i
|
||||
|
||||
for (j in 1 until lhsLength) {
|
||||
val match = if (lhs[j - 1] == rhs[i - 1]) 0 else 1
|
||||
|
||||
val costReplace = cost[j - 1] + match
|
||||
val costInsert = cost[j] + 1
|
||||
val costDelete = newCost[j - 1] + 1
|
||||
|
||||
newCost[j] = min(min(costInsert, costDelete), costReplace)
|
||||
}
|
||||
|
||||
val swap = cost
|
||||
cost = newCost
|
||||
newCost = swap
|
||||
}
|
||||
|
||||
return cost[lhsLength - 1]
|
||||
}
|
||||
|
||||
fun List<ShowResponse>.sortByTitle(string: String): List<ShowResponse> {
|
||||
val list = this.toMutableList()
|
||||
list.sortByTitle(string)
|
||||
return list
|
||||
}
|
||||
|
||||
fun MutableList<ShowResponse>.sortByTitle(string: String) {
|
||||
val temp: MutableMap<Int, Int> = mutableMapOf()
|
||||
for (i in 0 until this.size) {
|
||||
temp[i] = levenshtein(string.lowercase(), this[i].name.lowercase())
|
||||
}
|
||||
val c = temp.toList().sortedBy { (_, value) -> value }.toMap()
|
||||
val a = ArrayList(c.keys.toList().subList(0, min(this.size, 25)))
|
||||
val b = c.values.toList().subList(0, min(this.size, 25))
|
||||
for (i in b.indices.reversed()) {
|
||||
if (b[i] > 18 && i < a.size) a.removeAt(i)
|
||||
}
|
||||
val temp2 = this.toMutableList()
|
||||
this.clear()
|
||||
for (i in a.indices) {
|
||||
this.add(temp2[a[i]])
|
||||
}
|
||||
}
|
||||
|
||||
fun String.findBetween(a: String, b: String): String? {
|
||||
val string = substringAfter(a, "").substringBefore(b,"")
|
||||
return string.ifEmpty { null }
|
||||
}
|
||||
|
||||
fun ImageView.loadImage(url: String?, size: Int = 0) {
|
||||
if (!url.isNullOrEmpty()) {
|
||||
loadImage(FileUrl(url), size)
|
||||
}
|
||||
}
|
||||
|
||||
fun ImageView.loadImage(file: FileUrl?, size: Int = 0) {
|
||||
if (file?.url?.isNotEmpty() == true) {
|
||||
tryWith {
|
||||
val glideUrl = GlideUrl(file.url) { file.headers }
|
||||
Glide.with(this.context).load(glideUrl).transition(withCrossFade()).override(size).into(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SafeClickListener(
|
||||
private var defaultInterval: Int = 1000,
|
||||
private val onSafeCLick: (View) -> Unit
|
||||
) : View.OnClickListener {
|
||||
|
||||
private var lastTimeClicked: Long = 0
|
||||
|
||||
override fun onClick(v: View) {
|
||||
if (SystemClock.elapsedRealtime() - lastTimeClicked < defaultInterval) {
|
||||
return
|
||||
}
|
||||
lastTimeClicked = SystemClock.elapsedRealtime()
|
||||
onSafeCLick(v)
|
||||
}
|
||||
}
|
||||
|
||||
fun View.setSafeOnClickListener(onSafeClick: (View) -> Unit) {
|
||||
val safeClickListener = SafeClickListener {
|
||||
onSafeClick(it)
|
||||
}
|
||||
setOnClickListener(safeClickListener)
|
||||
}
|
||||
|
||||
suspend fun getSize(file: FileUrl): Double? {
|
||||
return tryWithSuspend {
|
||||
client.head(file.url, file.headers, timeout = 1000).size?.toDouble()?.div(1024 * 1024)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getSize(file: String): Double? {
|
||||
return getSize(FileUrl(file))
|
||||
}
|
||||
|
||||
|
||||
abstract class GesturesListener : GestureDetector.SimpleOnGestureListener() {
|
||||
private var timer: Timer? = null //at class level;
|
||||
private val delay: Long = 200
|
||||
|
||||
override fun onSingleTapUp(e: MotionEvent): Boolean {
|
||||
processSingleClickEvent(e)
|
||||
return super.onSingleTapUp(e)
|
||||
}
|
||||
|
||||
override fun onLongPress(e: MotionEvent) {
|
||||
processLongClickEvent(e)
|
||||
super.onLongPress(e)
|
||||
}
|
||||
|
||||
override fun onDoubleTap(e: MotionEvent): Boolean {
|
||||
processDoubleClickEvent(e)
|
||||
return super.onDoubleTap(e)
|
||||
}
|
||||
|
||||
override fun onScroll(e1: MotionEvent?, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean {
|
||||
onScrollYClick(distanceY)
|
||||
onScrollXClick(distanceX)
|
||||
return super.onScroll(e1, e2, distanceX, distanceY)
|
||||
}
|
||||
|
||||
private fun processSingleClickEvent(e: MotionEvent) {
|
||||
val handler = Handler(Looper.getMainLooper())
|
||||
val mRunnable = Runnable {
|
||||
onSingleClick(e)
|
||||
}
|
||||
timer = Timer().apply {
|
||||
schedule(object : TimerTask() {
|
||||
override fun run() {
|
||||
handler.post(mRunnable)
|
||||
}
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
|
||||
private fun processDoubleClickEvent(e: MotionEvent) {
|
||||
timer?.apply {
|
||||
cancel()
|
||||
purge()
|
||||
}
|
||||
onDoubleClick(e)
|
||||
}
|
||||
|
||||
private fun processLongClickEvent(e: MotionEvent) {
|
||||
timer?.apply {
|
||||
cancel()
|
||||
purge()
|
||||
}
|
||||
onLongClick(e)
|
||||
}
|
||||
|
||||
open fun onSingleClick(event: MotionEvent) {}
|
||||
open fun onDoubleClick(event: MotionEvent) {}
|
||||
open fun onScrollYClick(y: Float) {}
|
||||
open fun onScrollXClick(y: Float) {}
|
||||
open fun onLongClick(event: MotionEvent) {}
|
||||
}
|
||||
|
||||
fun View.circularReveal(ex: Int, ey: Int, subX: Boolean, time: Long) {
|
||||
ViewAnimationUtils.createCircularReveal(
|
||||
this,
|
||||
if (subX) (ex - x.toInt()) else ex,
|
||||
ey - y.toInt(),
|
||||
0f,
|
||||
max(height, width).toFloat()
|
||||
).setDuration(time).start()
|
||||
}
|
||||
|
||||
fun openLinkInBrowser(link: String?) {
|
||||
tryWith {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link))
|
||||
currContext()?.startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveImageToDownloads(title: String, bitmap: Bitmap, context: Context) {
|
||||
FileProvider.getUriForFile(
|
||||
context,
|
||||
"$APPLICATION_ID.provider",
|
||||
saveImage(
|
||||
bitmap,
|
||||
Environment.getExternalStorageDirectory().absolutePath + "/" + Environment.DIRECTORY_DOWNLOADS,
|
||||
title
|
||||
) ?: return
|
||||
)
|
||||
}
|
||||
|
||||
fun shareImage(title: String, bitmap: Bitmap, context: Context) {
|
||||
|
||||
val contentUri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"$APPLICATION_ID.provider",
|
||||
saveImage(bitmap, context.cacheDir.absolutePath, title) ?: return
|
||||
)
|
||||
|
||||
val intent = Intent(Intent.ACTION_SEND)
|
||||
intent.type = "image/png"
|
||||
intent.putExtra(Intent.EXTRA_TEXT, title)
|
||||
intent.putExtra(Intent.EXTRA_STREAM, contentUri)
|
||||
context.startActivity(Intent.createChooser(intent, "Share $title"))
|
||||
}
|
||||
|
||||
fun saveImage(image: Bitmap, path: String, imageFileName: String): File? {
|
||||
val imageFile = File(path, "$imageFileName.png")
|
||||
return tryWith {
|
||||
val fOut: OutputStream = FileOutputStream(imageFile)
|
||||
image.compress(Bitmap.CompressFormat.PNG, 0, fOut)
|
||||
fOut.close()
|
||||
scanFile(imageFile.absolutePath, currContext()!!)
|
||||
toast(String.format(currContext()!!.getString(R.string.saved_to_path, path)))
|
||||
imageFile
|
||||
}
|
||||
}
|
||||
|
||||
private fun scanFile(path: String, context: Context) {
|
||||
MediaScannerConnection.scanFile(context, arrayOf(path), null) { p, _ ->
|
||||
logger("Finished scanning $p")
|
||||
}
|
||||
}
|
||||
|
||||
class MediaPageTransformer : ViewPager2.PageTransformer {
|
||||
private fun parallax(view: View, position: Float) {
|
||||
if (position > -1 && position < 1) {
|
||||
val width = view.width.toFloat()
|
||||
view.translationX = -(position * width * 0.8f)
|
||||
}
|
||||
}
|
||||
|
||||
override fun transformPage(view: View, position: Float) {
|
||||
|
||||
val bannerContainer = view.findViewById<View>(R.id.itemCompactBanner)
|
||||
parallax(bannerContainer, position)
|
||||
}
|
||||
}
|
||||
|
||||
class NoGestureSubsamplingImageView(context: Context?, attr: AttributeSet?) :
|
||||
SubsamplingScaleImageView(context, attr) {
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
fun copyToClipboard(string: String, toast: Boolean = true) {
|
||||
val activity = currContext() ?: return
|
||||
val clipboard = getSystemService(activity, ClipboardManager::class.java)
|
||||
val clip = ClipData.newPlainText("label", string)
|
||||
clipboard?.setPrimaryClip(clip)
|
||||
if (toast) snackString(activity.getString(R.string.copied_text, string))
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
fun countDown(media: Media, view: ViewGroup) {
|
||||
if (media.anime?.nextAiringEpisode != null && media.anime.nextAiringEpisodeTime != null && (media.anime.nextAiringEpisodeTime!! - System.currentTimeMillis() / 1000) <= 86400 * 7.toLong()) {
|
||||
val v = ItemCountDownBinding.inflate(LayoutInflater.from(view.context), view, false)
|
||||
view.addView(v.root, 0)
|
||||
v.mediaCountdownText.text =
|
||||
currActivity()?.getString(R.string.episode_release_countdown, media.anime.nextAiringEpisode!! + 1)
|
||||
|
||||
object : CountDownTimer((media.anime.nextAiringEpisodeTime!! + 10000) * 1000 - System.currentTimeMillis(), 1000) {
|
||||
override fun onTick(millisUntilFinished: Long) {
|
||||
val a = millisUntilFinished / 1000
|
||||
v.mediaCountdown.text = currActivity()?.getString(
|
||||
R.string.time_format,
|
||||
a / 86400,
|
||||
a % 86400 / 3600,
|
||||
a % 86400 % 3600 / 60,
|
||||
a % 86400 % 3600 % 60
|
||||
)
|
||||
}
|
||||
|
||||
override fun onFinish() {
|
||||
v.mediaCountdownContainer.visibility = View.GONE
|
||||
snackString(currContext()?.getString(R.string.congrats_vro))
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
}
|
||||
|
||||
fun MutableMap<String, Genre>.checkId(id: Int): Boolean {
|
||||
this.forEach {
|
||||
if (it.value.id == id) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun MutableMap<String, Genre>.checkGenreTime(genre: String): Boolean {
|
||||
if (containsKey(genre))
|
||||
return (System.currentTimeMillis() - get(genre)!!.time) >= (1000 * 60 * 60 * 24 * 7)
|
||||
return true
|
||||
}
|
||||
|
||||
fun setSlideIn(uiSettings: UserInterfaceSettings) = AnimationSet(false).apply {
|
||||
if (uiSettings.layoutAnimations) {
|
||||
var animation: Animation = AlphaAnimation(0.0f, 1.0f)
|
||||
animation.duration = (500 * uiSettings.animationSpeed).toLong()
|
||||
animation.interpolator = AccelerateDecelerateInterpolator()
|
||||
addAnimation(animation)
|
||||
|
||||
animation = TranslateAnimation(
|
||||
Animation.RELATIVE_TO_SELF, 1.0f,
|
||||
Animation.RELATIVE_TO_SELF, 0f,
|
||||
Animation.RELATIVE_TO_SELF, 0.0f,
|
||||
Animation.RELATIVE_TO_SELF, 0f
|
||||
)
|
||||
|
||||
animation.duration = (750 * uiSettings.animationSpeed).toLong()
|
||||
animation.interpolator = OvershootInterpolator(1.1f)
|
||||
addAnimation(animation)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSlideUp(uiSettings: UserInterfaceSettings) = AnimationSet(false).apply {
|
||||
if (uiSettings.layoutAnimations) {
|
||||
var animation: Animation = AlphaAnimation(0.0f, 1.0f)
|
||||
animation.duration = (500 * uiSettings.animationSpeed).toLong()
|
||||
animation.interpolator = AccelerateDecelerateInterpolator()
|
||||
addAnimation(animation)
|
||||
|
||||
animation = TranslateAnimation(
|
||||
Animation.RELATIVE_TO_SELF, 0.0f,
|
||||
Animation.RELATIVE_TO_SELF, 0f,
|
||||
Animation.RELATIVE_TO_SELF, 1.0f,
|
||||
Animation.RELATIVE_TO_SELF, 0f
|
||||
)
|
||||
|
||||
animation.duration = (750 * uiSettings.animationSpeed).toLong()
|
||||
animation.interpolator = OvershootInterpolator(1.1f)
|
||||
addAnimation(animation)
|
||||
}
|
||||
}
|
||||
|
||||
class EmptyAdapter(private val count: Int) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
return EmptyViewHolder(View(parent.context))
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {}
|
||||
|
||||
override fun getItemCount(): Int = count
|
||||
|
||||
inner class EmptyViewHolder(view: View) : RecyclerView.ViewHolder(view)
|
||||
}
|
||||
|
||||
fun toast(string: String?) {
|
||||
if (string != null) {
|
||||
logger(string)
|
||||
MainScope().launch {
|
||||
Toast.makeText(currActivity()?.application ?: return@launch, string, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun snackString(s: String?, activity: Activity? = null, clipboard: String? = null) {
|
||||
if (s != null) {
|
||||
(activity ?: currActivity())?.apply {
|
||||
runOnUiThread {
|
||||
val snackBar = Snackbar.make(window.decorView.findViewById(android.R.id.content), s, Snackbar.LENGTH_LONG)
|
||||
snackBar.view.apply {
|
||||
updateLayoutParams<FrameLayout.LayoutParams> {
|
||||
gravity = (Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM)
|
||||
width = WRAP_CONTENT
|
||||
}
|
||||
translationY = -(navBarHeight.dp + 32f)
|
||||
translationZ = 32f
|
||||
updatePadding(16f.px, right = 16f.px)
|
||||
setOnClickListener {
|
||||
snackBar.dismiss()
|
||||
}
|
||||
setOnLongClickListener {
|
||||
copyToClipboard(clipboard ?: s, false)
|
||||
toast(getString(R.string.copied_to_clipboard))
|
||||
true
|
||||
}
|
||||
}
|
||||
snackBar.show()
|
||||
}
|
||||
}
|
||||
logger(s)
|
||||
}
|
||||
}
|
||||
|
||||
open class NoPaddingArrayAdapter<T>(context: Context, layoutId: Int, items: List<T>) : ArrayAdapter<T>(context, layoutId, items) {
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val view = super.getView(position, convertView, parent)
|
||||
view.setPadding(0, view.paddingTop, view.paddingRight, view.paddingBottom)
|
||||
(view as TextView).setTextColor(Color.WHITE)
|
||||
return view
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
class SpinnerNoSwipe : androidx.appcompat.widget.AppCompatSpinner {
|
||||
private var mGestureDetector: GestureDetector? = null
|
||||
|
||||
constructor(context: Context) : super(context) {
|
||||
setup()
|
||||
}
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {
|
||||
setup()
|
||||
}
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
|
||||
setup()
|
||||
}
|
||||
|
||||
private fun setup() {
|
||||
mGestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
|
||||
override fun onSingleTapUp(e: MotionEvent): Boolean {
|
||||
return performClick()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
mGestureDetector!!.onTouchEvent(event)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
class CustomBottomNavBar @JvmOverloads constructor(
|
||||
context: Context, attrs: AttributeSet? = null
|
||||
) : BottomNavigationView(context, attrs) {
|
||||
init {
|
||||
ViewUtils.doOnApplyWindowInsets(
|
||||
this
|
||||
) { view, insets, initialPadding ->
|
||||
initialPadding.bottom = 0
|
||||
updateLayoutParams<MarginLayoutParams> { bottomMargin = navBarHeight }
|
||||
initialPadding.applyToView(view)
|
||||
insets
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getCurrentBrightnessValue(context: Context): Float {
|
||||
fun getMax(): Int {
|
||||
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
|
||||
val fields: Array<Field> = powerManager.javaClass.declaredFields
|
||||
for (field in fields) {
|
||||
if (field.name.equals("BRIGHTNESS_ON")) {
|
||||
field.isAccessible = true
|
||||
return try {
|
||||
field.get(powerManager)?.toString()?.toInt() ?: 255
|
||||
} catch (e: IllegalAccessException) {
|
||||
255
|
||||
}
|
||||
}
|
||||
}
|
||||
return 255
|
||||
}
|
||||
|
||||
fun getCur(): Float {
|
||||
return Settings.System.getInt(context.contentResolver, Settings.System.SCREEN_BRIGHTNESS, 127).toFloat()
|
||||
}
|
||||
|
||||
return brightnessConverter(getCur() / getMax(), true)
|
||||
}
|
||||
|
||||
fun brightnessConverter(it: Float, fromLog: Boolean) =
|
||||
clamp(
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
|
||||
if (fromLog) log2((it * 256f)) * 12.5f / 100f else 2f.pow(it * 100f / 12.5f) / 256f
|
||||
else it, 0.001f, 1f
|
||||
)
|
||||
|
||||
|
||||
fun checkCountry(context: Context): Boolean {
|
||||
val telMgr = context.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager
|
||||
return when (telMgr.simState) {
|
||||
TelephonyManager.SIM_STATE_ABSENT -> {
|
||||
val tz = TimeZone.getDefault().id
|
||||
tz.equals("Asia/Kolkata", ignoreCase = true)
|
||||
}
|
||||
|
||||
TelephonyManager.SIM_STATE_READY -> {
|
||||
val countryCodeValue = telMgr.networkCountryIso
|
||||
countryCodeValue.equals("in", ignoreCase = true)
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun View.pop() {
|
||||
currActivity()?.runOnUiThread {
|
||||
ObjectAnimator.ofFloat(this@pop, "scaleX", 1f, 1.25f).setDuration(120).start()
|
||||
ObjectAnimator.ofFloat(this@pop, "scaleY", 1f, 1.25f).setDuration(120).start()
|
||||
}
|
||||
delay(120)
|
||||
currActivity()?.runOnUiThread {
|
||||
ObjectAnimator.ofFloat(this@pop, "scaleX", 1.25f, 1f).setDuration(100).start()
|
||||
ObjectAnimator.ofFloat(this@pop, "scaleY", 1.25f, 1f).setDuration(100).start()
|
||||
}
|
||||
delay(100)
|
||||
}
|
233
app/src/main/java/ani/dantotsu/MainActivity.kt
Normal file
233
app/src/main/java/ani/dantotsu/MainActivity.kt
Normal file
|
@ -0,0 +1,233 @@
|
|||
package ani.dantotsu
|
||||
|
||||
import android.animation.ObjectAnimator
|
||||
import android.content.Intent
|
||||
import android.graphics.drawable.Animatable
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.Settings
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.animation.AnticipateInterpolator
|
||||
import android.widget.TextView
|
||||
import androidx.activity.addCallback
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.animation.doOnEnd
|
||||
import androidx.core.view.doOnAttach
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import ani.dantotsu.aniyomi.anime.AnimeExtensionManager
|
||||
import ani.dantotsu.connections.anilist.Anilist
|
||||
import ani.dantotsu.connections.anilist.AnilistHomeViewModel
|
||||
import ani.dantotsu.databinding.ActivityMainBinding
|
||||
import ani.dantotsu.databinding.SplashScreenBinding
|
||||
import ani.dantotsu.home.AnimeFragment
|
||||
import ani.dantotsu.home.HomeFragment
|
||||
import ani.dantotsu.home.LoginFragment
|
||||
import ani.dantotsu.home.MangaFragment
|
||||
import ani.dantotsu.home.NoInternet
|
||||
import ani.dantotsu.media.MediaDetailsActivity
|
||||
import ani.dantotsu.others.CustomBottomDialog
|
||||
import ani.dantotsu.parsers.AnimeSources
|
||||
import ani.dantotsu.settings.UserInterfaceSettings
|
||||
import ani.dantotsu.subcriptions.Subscription.Companion.startSubscription
|
||||
import io.noties.markwon.Markwon
|
||||
import io.noties.markwon.SoftBreakAddsNewLinePlugin
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import nl.joery.animatedbottombar.AnimatedBottomBar
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.Serializable
|
||||
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
private val scope = lifecycleScope
|
||||
private var load = false
|
||||
|
||||
private var uiSettings = UserInterfaceSettings()
|
||||
private val animeExtensionManager: AnimeExtensionManager by injectLazy()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
val myScope = CoroutineScope(Dispatchers.Default)
|
||||
myScope.launch {
|
||||
animeExtensionManager.findAvailableExtensions()
|
||||
AnimeSources.init(animeExtensionManager.installedExtensionsFlow)
|
||||
|
||||
}
|
||||
|
||||
var doubleBackToExitPressedOnce = false
|
||||
onBackPressedDispatcher.addCallback(this) {
|
||||
if (doubleBackToExitPressedOnce) {
|
||||
finish()
|
||||
}
|
||||
doubleBackToExitPressedOnce = true
|
||||
snackString(this@MainActivity.getString(R.string.back_to_exit))
|
||||
Handler(Looper.getMainLooper()).postDelayed(
|
||||
{ doubleBackToExitPressedOnce = false },
|
||||
2000
|
||||
)
|
||||
}
|
||||
|
||||
binding.root.isMotionEventSplittingEnabled = false
|
||||
|
||||
lifecycleScope.launch {
|
||||
val splash = SplashScreenBinding.inflate(layoutInflater)
|
||||
binding.root.addView(splash.root)
|
||||
(splash.splashImage.drawable as Animatable).start()
|
||||
|
||||
// Wait for 2 seconds (2000 milliseconds)
|
||||
delay(2000)
|
||||
|
||||
// Now perform the animation
|
||||
ObjectAnimator.ofFloat(
|
||||
splash.root,
|
||||
View.TRANSLATION_Y,
|
||||
0f,
|
||||
-splash.root.height.toFloat()
|
||||
).apply {
|
||||
interpolator = AnticipateInterpolator()
|
||||
duration = 200L
|
||||
doOnEnd { binding.root.removeView(splash.root) }
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
binding.root.doOnAttach {
|
||||
initActivity(this)
|
||||
uiSettings = loadData("ui_settings") ?: uiSettings
|
||||
selectedOption = uiSettings.defaultStartUpTab
|
||||
binding.navbarContainer.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
bottomMargin = navBarHeight
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOnline(this)) {
|
||||
snackString(this@MainActivity.getString(R.string.no_internet_connection))
|
||||
startActivity(Intent(this, NoInternet::class.java))
|
||||
} else {
|
||||
val model: AnilistHomeViewModel by viewModels()
|
||||
model.genres.observe(this) {
|
||||
if (it != null) {
|
||||
if (it) {
|
||||
val navbar = binding.navbar
|
||||
bottomBar = navbar
|
||||
navbar.visibility = View.VISIBLE
|
||||
binding.mainProgressBar.visibility = View.GONE
|
||||
val mainViewPager = binding.viewpager
|
||||
mainViewPager.isUserInputEnabled = false
|
||||
mainViewPager.adapter = ViewPagerAdapter(supportFragmentManager, lifecycle)
|
||||
mainViewPager.setPageTransformer(ZoomOutPageTransformer(uiSettings))
|
||||
navbar.setOnTabSelectListener(object :
|
||||
AnimatedBottomBar.OnTabSelectListener {
|
||||
override fun onTabSelected(
|
||||
lastIndex: Int,
|
||||
lastTab: AnimatedBottomBar.Tab?,
|
||||
newIndex: Int,
|
||||
newTab: AnimatedBottomBar.Tab
|
||||
) {
|
||||
navbar.animate().translationZ(12f).setDuration(200).start()
|
||||
selectedOption = newIndex
|
||||
mainViewPager.setCurrentItem(newIndex, false)
|
||||
}
|
||||
})
|
||||
navbar.selectTabAt(selectedOption)
|
||||
mainViewPager.post { mainViewPager.setCurrentItem(selectedOption, false) }
|
||||
} else {
|
||||
binding.mainProgressBar.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
//Load Data
|
||||
if (!load) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
model.loadMain(this@MainActivity)
|
||||
val id = intent.extras?.getInt("mediaId", 0)
|
||||
val isMAL = intent.extras?.getBoolean("mal") ?: false
|
||||
val cont = intent.extras?.getBoolean("continue") ?: false
|
||||
if (id != null && id != 0) {
|
||||
val media = withContext(Dispatchers.IO) {
|
||||
Anilist.query.getMedia(id, isMAL)
|
||||
}
|
||||
if (media != null) {
|
||||
media.cameFromContinue = cont
|
||||
startActivity(
|
||||
Intent(this@MainActivity, MediaDetailsActivity::class.java)
|
||||
.putExtra("media", media as Serializable)
|
||||
)
|
||||
} else {
|
||||
snackString(this@MainActivity.getString(R.string.anilist_not_found))
|
||||
}
|
||||
}
|
||||
delay(500)
|
||||
startSubscription()
|
||||
}
|
||||
load = true
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
if (loadData<Boolean>("allow_opening_links", this) != true) {
|
||||
CustomBottomDialog.newInstance().apply {
|
||||
title = "Allow Dantotsu to automatically open Anilist & MAL Links?"
|
||||
val md = "Open settings & click +Add Links & select Anilist & Mal urls"
|
||||
addView(TextView(this@MainActivity).apply {
|
||||
val markWon =
|
||||
Markwon.builder(this@MainActivity)
|
||||
.usePlugin(SoftBreakAddsNewLinePlugin.create()).build()
|
||||
markWon.setMarkdown(this, md)
|
||||
})
|
||||
|
||||
setNegativeButton(this@MainActivity.getString(R.string.no)) {
|
||||
saveData("allow_opening_links", true, this@MainActivity)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
setPositiveButton(this@MainActivity.getString(R.string.yes)) {
|
||||
saveData("allow_opening_links", true, this@MainActivity)
|
||||
tryWith(true) {
|
||||
startActivity(
|
||||
Intent(Settings.ACTION_APP_OPEN_BY_DEFAULT_SETTINGS)
|
||||
.setData(Uri.parse("package:$packageName"))
|
||||
)
|
||||
}
|
||||
}
|
||||
}.show(supportFragmentManager, "dialog")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//ViewPager
|
||||
private class ViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifecycle) :
|
||||
FragmentStateAdapter(fragmentManager, lifecycle) {
|
||||
|
||||
override fun getItemCount(): Int = 3
|
||||
|
||||
override fun createFragment(position: Int): Fragment {
|
||||
when (position) {
|
||||
0 -> return AnimeFragment()
|
||||
1 -> return if (Anilist.token != null) HomeFragment() else LoginFragment()
|
||||
2 -> return MangaFragment()
|
||||
}
|
||||
return LoginFragment()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
229
app/src/main/java/ani/dantotsu/Network.kt
Normal file
229
app/src/main/java/ani/dantotsu/Network.kt
Normal file
|
@ -0,0 +1,229 @@
|
|||
package ani.dantotsu
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import ani.dantotsu.others.webview.CloudFlare
|
||||
import ani.dantotsu.others.webview.WebViewBottomDialog
|
||||
import com.lagradost.nicehttp.Requests
|
||||
import com.lagradost.nicehttp.ResponseParser
|
||||
import com.lagradost.nicehttp.addGenericDns
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.InternalSerializationApi
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.serializer
|
||||
import okhttp3.Cache
|
||||
import okhttp3.OkHttpClient
|
||||
import java.io.File
|
||||
import java.io.PrintWriter
|
||||
import java.io.Serializable
|
||||
import java.io.StringWriter
|
||||
import java.util.concurrent.*
|
||||
import kotlin.reflect.KClass
|
||||
import kotlin.reflect.KFunction
|
||||
|
||||
val defaultHeaders = mapOf(
|
||||
"User-Agent" to
|
||||
"Mozilla/5.0 (Linux; Android %s; %s) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Mobile Safari/537.36"
|
||||
.format(Build.VERSION.RELEASE, Build.MODEL)
|
||||
)
|
||||
lateinit var cache: Cache
|
||||
|
||||
lateinit var okHttpClient: OkHttpClient
|
||||
lateinit var client: Requests
|
||||
|
||||
fun initializeNetwork(context: Context) {
|
||||
val dns = loadData<Int>("settings_dns")
|
||||
cache = Cache(
|
||||
File(context.cacheDir, "http_cache"),
|
||||
5 * 1024L * 1024L // 5 MiB
|
||||
)
|
||||
okHttpClient = OkHttpClient.Builder()
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.cache(cache)
|
||||
.apply {
|
||||
when (dns) {
|
||||
1 -> addGoogleDns()
|
||||
2 -> addCloudFlareDns()
|
||||
3 -> addAdGuardDns()
|
||||
}
|
||||
}
|
||||
.build()
|
||||
client = Requests(
|
||||
okHttpClient,
|
||||
defaultHeaders,
|
||||
defaultCacheTime = 6,
|
||||
defaultCacheTimeUnit = TimeUnit.HOURS,
|
||||
responseParser = Mapper
|
||||
)
|
||||
}
|
||||
|
||||
object Mapper : ResponseParser {
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
val json = Json {
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
}
|
||||
|
||||
@OptIn(InternalSerializationApi::class)
|
||||
override fun <T : Any> parse(text: String, kClass: KClass<T>): T {
|
||||
return json.decodeFromString(kClass.serializer(), text)
|
||||
}
|
||||
|
||||
override fun <T : Any> parseSafe(text: String, kClass: KClass<T>): T? {
|
||||
return tryWith {
|
||||
parse(text, kClass)
|
||||
}
|
||||
}
|
||||
|
||||
override fun writeValueAsString(obj: Any): String {
|
||||
return json.encodeToString(obj)
|
||||
}
|
||||
|
||||
inline fun <reified T> parse(text: String): T {
|
||||
return json.decodeFromString(text)
|
||||
}
|
||||
}
|
||||
|
||||
fun <A, B> Collection<A>.asyncMap(f: suspend (A) -> B): List<B> = runBlocking {
|
||||
map { async { f(it) } }.map { it.await() }
|
||||
}
|
||||
|
||||
fun <A, B> Collection<A>.asyncMapNotNull(f: suspend (A) -> B?): List<B> = runBlocking {
|
||||
map { async { f(it) } }.mapNotNull { it.await() }
|
||||
}
|
||||
|
||||
fun logError(e: Throwable, post: Boolean = true, snackbar: Boolean = true) {
|
||||
val sw = StringWriter()
|
||||
val pw = PrintWriter(sw)
|
||||
e.printStackTrace(pw)
|
||||
val stackTrace: String = sw.toString()
|
||||
if (post) {
|
||||
if (snackbar)
|
||||
snackString(e.localizedMessage, null, stackTrace)
|
||||
else
|
||||
toast(e.localizedMessage)
|
||||
}
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
fun <T> tryWith(post: Boolean = false, snackbar: Boolean = true, call: () -> T): T? {
|
||||
return try {
|
||||
call.invoke()
|
||||
} catch (e: Throwable) {
|
||||
logError(e, post, snackbar)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun <T> tryWithSuspend(post: Boolean = false, snackbar: Boolean = true, call: suspend () -> T): T? {
|
||||
return try {
|
||||
call.invoke()
|
||||
} catch (e: Throwable) {
|
||||
logError(e, post, snackbar)
|
||||
null
|
||||
} catch (e: CancellationException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A url, which can also have headers
|
||||
* **/
|
||||
data class FileUrl(
|
||||
val url: String,
|
||||
val headers: Map<String, String> = mapOf()
|
||||
) : Serializable {
|
||||
companion object {
|
||||
operator fun get(url: String?, headers: Map<String, String> = mapOf()): FileUrl? {
|
||||
return FileUrl(url ?: return null, headers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Credits to leg
|
||||
data class Lazier<T>(
|
||||
val factory: () -> T,
|
||||
val name: String,
|
||||
val lClass: KFunction<T>? = null
|
||||
) {
|
||||
val get = lazy { factory() ?: lClass?.call() }
|
||||
}
|
||||
|
||||
|
||||
fun <T> lazyList(vararg objects: Pair<String, () -> T>): List<Lazier<T>> {
|
||||
return objects.map {
|
||||
Lazier(it.second, it.first)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun <T> T.printIt(pre: String = ""): T {
|
||||
println("$pre$this")
|
||||
return this
|
||||
}
|
||||
|
||||
|
||||
fun OkHttpClient.Builder.addGoogleDns() = (
|
||||
addGenericDns(
|
||||
"https://dns.google/dns-query",
|
||||
listOf(
|
||||
"8.8.4.4",
|
||||
"8.8.8.8"
|
||||
)
|
||||
))
|
||||
|
||||
fun OkHttpClient.Builder.addCloudFlareDns() = (
|
||||
addGenericDns(
|
||||
"https://cloudflare-dns.com/dns-query",
|
||||
listOf(
|
||||
"1.1.1.1",
|
||||
"1.0.0.1",
|
||||
"2606:4700:4700::1111",
|
||||
"2606:4700:4700::1001"
|
||||
)
|
||||
))
|
||||
|
||||
fun OkHttpClient.Builder.addAdGuardDns() = (
|
||||
addGenericDns(
|
||||
"https://dns.adguard.com/dns-query",
|
||||
listOf(
|
||||
// "Non-filtering"
|
||||
"94.140.14.140",
|
||||
"94.140.14.141",
|
||||
)
|
||||
))
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
suspend fun webViewInterface(webViewDialog: WebViewBottomDialog): Map<String, String>? {
|
||||
var map : Map<String,String>? = null
|
||||
|
||||
val latch = CountDownLatch(1)
|
||||
webViewDialog.callback = {
|
||||
map = it
|
||||
latch.countDown()
|
||||
}
|
||||
val fragmentManager = (currContext() as FragmentActivity?)?.supportFragmentManager ?: return null
|
||||
webViewDialog.show(fragmentManager, "web-view")
|
||||
delay(0)
|
||||
latch.await(2,TimeUnit.MINUTES)
|
||||
return map
|
||||
}
|
||||
|
||||
suspend fun webViewInterface(type: String, url: FileUrl): Map<String, String>? {
|
||||
val webViewDialog: WebViewBottomDialog = when (type) {
|
||||
"Cloudflare" -> CloudFlare.newInstance(url)
|
||||
else -> return null
|
||||
}
|
||||
return webViewInterface(webViewDialog)
|
||||
}
|
||||
|
||||
suspend fun webViewInterface(type: String, url: String): Map<String, String>? {
|
||||
return webViewInterface(type,FileUrl(url))
|
||||
}
|
176
app/src/main/java/ani/dantotsu/aniyomi/LICENSE
Normal file
176
app/src/main/java/ani/dantotsu/aniyomi/LICENSE
Normal file
|
@ -0,0 +1,176 @@
|
|||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
3
app/src/main/java/ani/dantotsu/aniyomi/NOTICE.md
Normal file
3
app/src/main/java/ani/dantotsu/aniyomi/NOTICE.md
Normal file
|
@ -0,0 +1,3 @@
|
|||
NOTICE
|
||||
|
||||
This software includes code modified from Aniyomi, available at https://github.com/aniyomiorg/aniyomi/.
|
|
@ -0,0 +1,3 @@
|
|||
package ani.dantotsu.aniyomi
|
||||
|
||||
typealias PreferenceScreen = androidx.preference.PreferenceScreen
|
|
@ -0,0 +1,394 @@
|
|||
package ani.dantotsu.aniyomi.anime
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import ani.dantotsu.aniyomi.domain.source.anime.model.AnimeSourceData
|
||||
import ani.dantotsu.aniyomi.util.extension.InstallStep
|
||||
import ani.dantotsu.aniyomi.util.launchNow
|
||||
import ani.dantotsu.aniyomi.anime.api.AnimeExtensionGithubApi
|
||||
import ani.dantotsu.aniyomi.anime.model.AnimeExtension
|
||||
import ani.dantotsu.aniyomi.anime.model.AnimeLoadResult
|
||||
import ani.dantotsu.aniyomi.anime.util.AnimeExtensionInstallReceiver
|
||||
import ani.dantotsu.aniyomi.anime.util.AnimeExtensionInstaller
|
||||
import ani.dantotsu.aniyomi.anime.util.AnimeExtensionLoader
|
||||
import ani.dantotsu.aniyomi.animesource.AnimeCatalogueSource
|
||||
//import eu.kanade.tachiyomi.util.preference.plusAssign
|
||||
import ani.dantotsu.aniyomi.util.toast
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import logcat.LogPriority
|
||||
import rx.Observable
|
||||
import ani.dantotsu.aniyomi.util.logcat
|
||||
import ani.dantotsu.aniyomi.util.withUIContext
|
||||
|
||||
/**
|
||||
* The manager of anime extensions installed as another apk which extend the available sources. It handles
|
||||
* the retrieval of remotely available anime extensions as well as installing, updating and removing them.
|
||||
* To avoid malicious distribution, every anime extension must be signed and it will only be loaded if its
|
||||
* signature is trusted, otherwise the user will be prompted with a warning to trust it before being
|
||||
* loaded.
|
||||
*
|
||||
* @param context The application context.
|
||||
* @param preferences The application preferences.
|
||||
*/
|
||||
class AnimeExtensionManager(
|
||||
private val context: Context,
|
||||
) {
|
||||
|
||||
var isInitialized = false
|
||||
private set
|
||||
|
||||
/**
|
||||
* API where all the available anime extensions can be found.
|
||||
*/
|
||||
private val api = AnimeExtensionGithubApi()
|
||||
|
||||
/**
|
||||
* The installer which installs, updates and uninstalls the anime extensions.
|
||||
*/
|
||||
private val installer by lazy { AnimeExtensionInstaller(context) }
|
||||
|
||||
private val iconMap = mutableMapOf<String, Drawable>()
|
||||
|
||||
private val _installedAnimeExtensionsFlow = MutableStateFlow(emptyList<AnimeExtension.Installed>())
|
||||
val installedExtensionsFlow = _installedAnimeExtensionsFlow.asStateFlow()
|
||||
|
||||
private var subLanguagesEnabledOnFirstRun = false
|
||||
|
||||
fun getAppIconForSource(sourceId: Long): Drawable? {
|
||||
val pkgName = _installedAnimeExtensionsFlow.value.find { ext -> ext.sources.any { it.id == sourceId } }?.pkgName
|
||||
if (pkgName != null) {
|
||||
return iconMap[pkgName] ?: iconMap.getOrPut(pkgName) { context.packageManager.getApplicationIcon(pkgName) }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private val _availableAnimeExtensionsFlow = MutableStateFlow(emptyList<AnimeExtension.Available>())
|
||||
val availableExtensionsFlow = _availableAnimeExtensionsFlow.asStateFlow()
|
||||
|
||||
private var availableAnimeExtensionsSourcesData: Map<Long, AnimeSourceData> = emptyMap()
|
||||
|
||||
private fun setupAvailableAnimeExtensionsSourcesDataMap(animeextensions: List<AnimeExtension.Available>) {
|
||||
if (animeextensions.isEmpty()) return
|
||||
availableAnimeExtensionsSourcesData = animeextensions
|
||||
.flatMap { ext -> ext.sources.map { it.toAnimeSourceData() } }
|
||||
.associateBy { it.id }
|
||||
}
|
||||
|
||||
fun getSourceData(id: Long) = availableAnimeExtensionsSourcesData[id]
|
||||
|
||||
private val _untrustedAnimeExtensionsFlow = MutableStateFlow(emptyList<AnimeExtension.Untrusted>())
|
||||
val untrustedExtensionsFlow = _untrustedAnimeExtensionsFlow.asStateFlow()
|
||||
|
||||
init {
|
||||
initAnimeExtensions()
|
||||
AnimeExtensionInstallReceiver(AnimeInstallationListener()).register(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads and registers the installed animeextensions.
|
||||
*/
|
||||
private fun initAnimeExtensions() {
|
||||
val animeextensions = AnimeExtensionLoader.loadExtensions(context)
|
||||
logcat { "Loaded ${animeextensions.size} anime extensions" }
|
||||
for (result in animeextensions) {
|
||||
when (result) {
|
||||
is AnimeLoadResult.Success -> {
|
||||
logcat { "Loaded: ${result.extension.pkgName}" }
|
||||
for(source in result.extension.sources) {
|
||||
logcat { "Loaded: ${source.name}" }
|
||||
}
|
||||
val sc = result.extension.sources.first()
|
||||
if (sc is AnimeCatalogueSource) {
|
||||
//val res = sc.fetchSearchAnime(1, "spy x family", AnimeFilterList()).toBlocking().first()
|
||||
/*val newScope = CoroutineScope(Dispatchers.IO)
|
||||
newScope.launch {
|
||||
println("fetching popular anime")
|
||||
try {
|
||||
val res = sc.fetchPopularAnime(1).toBlocking().first()
|
||||
println("res111: $res")
|
||||
}
|
||||
catch (e: Exception) {
|
||||
println("Exception111: $e")
|
||||
}
|
||||
|
||||
}*/
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
logcat(LogPriority.ERROR) { "Error loading anime extension: $result" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_installedAnimeExtensionsFlow.value = animeextensions
|
||||
.filterIsInstance<AnimeLoadResult.Success>()
|
||||
.map { it.extension }
|
||||
|
||||
_untrustedAnimeExtensionsFlow.value = animeextensions
|
||||
.filterIsInstance<AnimeLoadResult.Untrusted>()
|
||||
.map { it.extension }
|
||||
|
||||
isInitialized = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the available anime extensions in the [api] and updates [availableExtensions].
|
||||
*/
|
||||
suspend fun findAvailableExtensions() {
|
||||
val extensions: List<AnimeExtension.Available> = try {
|
||||
api.findExtensions()
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR, e)
|
||||
withUIContext { context.toast("Could not update anime extensions") }
|
||||
emptyList()
|
||||
}
|
||||
|
||||
enableAdditionalSubLanguages(extensions)
|
||||
|
||||
_availableAnimeExtensionsFlow.value = extensions
|
||||
println("AnimeExtensions: $extensions")
|
||||
updatedInstalledAnimeExtensionsStatuses(extensions)
|
||||
setupAvailableAnimeExtensionsSourcesDataMap(extensions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables the additional sub-languages in the app first run. This addresses
|
||||
* the issue where users still need to enable some specific languages even when
|
||||
* the device language is inside that major group. As an example, if a user
|
||||
* has a zh device language, the app will also enable zh-Hans and zh-Hant.
|
||||
*
|
||||
* If the user have already changed the enabledLanguages preference value once,
|
||||
* the new languages will not be added to respect the user enabled choices.
|
||||
*/
|
||||
private fun enableAdditionalSubLanguages(animeextensions: List<AnimeExtension.Available>) {
|
||||
if (subLanguagesEnabledOnFirstRun || animeextensions.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Use the source lang as some aren't present on the animeextension level.
|
||||
/*val availableLanguages = animeextensions
|
||||
.flatMap(AnimeExtension.Available::sources)
|
||||
.distinctBy(AvailableAnimeSources::lang)
|
||||
.map(AvailableAnimeSources::lang)
|
||||
|
||||
val deviceLanguage = Locale.getDefault().language
|
||||
val defaultLanguages = preferences.enabledLanguages().defaultValue()
|
||||
val languagesToEnable = availableLanguages.filter {
|
||||
it != deviceLanguage && it.startsWith(deviceLanguage)
|
||||
}
|
||||
|
||||
preferences.enabledLanguages().set(defaultLanguages + languagesToEnable)*/
|
||||
subLanguagesEnabledOnFirstRun = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the update field of the installed animeextensions with the given [availableAnimeExtensions].
|
||||
*
|
||||
* @param availableAnimeExtensions The list of animeextensions given by the [api].
|
||||
*/
|
||||
private fun updatedInstalledAnimeExtensionsStatuses(availableAnimeExtensions: List<AnimeExtension.Available>) {
|
||||
if (availableAnimeExtensions.isEmpty()) {
|
||||
//preferences.animeExtensionUpdatesCount().set(0)
|
||||
return
|
||||
}
|
||||
|
||||
val mutInstalledAnimeExtensions = _installedAnimeExtensionsFlow.value.toMutableList()
|
||||
var changed = false
|
||||
|
||||
for ((index, installedExt) in mutInstalledAnimeExtensions.withIndex()) {
|
||||
val pkgName = installedExt.pkgName
|
||||
val availableExt = availableAnimeExtensions.find { it.pkgName == pkgName }
|
||||
|
||||
if (!installedExt.isUnofficial && availableExt == null && !installedExt.isObsolete) {
|
||||
mutInstalledAnimeExtensions[index] = installedExt.copy(isObsolete = true)
|
||||
changed = true
|
||||
} else if (availableExt != null) {
|
||||
val hasUpdate = installedExt.updateExists(availableExt)
|
||||
|
||||
if (installedExt.hasUpdate != hasUpdate) {
|
||||
mutInstalledAnimeExtensions[index] = installedExt.copy(hasUpdate = hasUpdate)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
_installedAnimeExtensionsFlow.value = mutInstalledAnimeExtensions
|
||||
}
|
||||
updatePendingUpdatesCount()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable of the installation process for the given anime extension. It will complete
|
||||
* once the anime extension is installed or throws an error. The process will be canceled if
|
||||
* unsubscribed before its completion.
|
||||
*
|
||||
* @param extension The anime extension to be installed.
|
||||
*/
|
||||
fun installExtension(extension: AnimeExtension.Available): Observable<InstallStep> {
|
||||
return installer.downloadAndInstall(api.getApkUrl(extension), extension)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable of the installation process for the given anime extension. It will complete
|
||||
* once the anime extension is updated or throws an error. The process will be canceled if
|
||||
* unsubscribed before its completion.
|
||||
*
|
||||
* @param extension The anime extension to be updated.
|
||||
*/
|
||||
fun updateExtension(extension: AnimeExtension.Installed): Observable<InstallStep> {
|
||||
val availableExt = _availableAnimeExtensionsFlow.value.find { it.pkgName == extension.pkgName }
|
||||
?: return Observable.empty()
|
||||
return installExtension(availableExt)
|
||||
}
|
||||
|
||||
fun cancelInstallUpdateExtension(extension: AnimeExtension) {
|
||||
installer.cancelInstall(extension.pkgName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets to "installing" status of an anime extension installation.
|
||||
*
|
||||
* @param downloadId The id of the download.
|
||||
*/
|
||||
fun setInstalling(downloadId: Long) {
|
||||
installer.updateInstallStep(downloadId, InstallStep.Installing)
|
||||
}
|
||||
|
||||
fun updateInstallStep(downloadId: Long, step: InstallStep) {
|
||||
installer.updateInstallStep(downloadId, step)
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstalls the anime extension that matches the given package name.
|
||||
*
|
||||
* @param pkgName The package name of the application to uninstall.
|
||||
*/
|
||||
fun uninstallExtension(pkgName: String) {
|
||||
installer.uninstallApk(pkgName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the given signature to the list of trusted signatures. It also loads in background the
|
||||
* anime extensions that match this signature.
|
||||
*
|
||||
* @param signature The signature to whitelist.
|
||||
*/
|
||||
fun trustSignature(signature: String) {
|
||||
val untrustedSignatures = _untrustedAnimeExtensionsFlow.value.map { it.signatureHash }.toSet()
|
||||
if (signature !in untrustedSignatures) return
|
||||
|
||||
AnimeExtensionLoader.trustedSignatures += signature
|
||||
//preferences.trustedSignatures() += signature
|
||||
|
||||
val nowTrustedAnimeExtensions = _untrustedAnimeExtensionsFlow.value.filter { it.signatureHash == signature }
|
||||
_untrustedAnimeExtensionsFlow.value -= nowTrustedAnimeExtensions
|
||||
|
||||
val ctx = context
|
||||
launchNow {
|
||||
nowTrustedAnimeExtensions
|
||||
.map { animeextension ->
|
||||
async { AnimeExtensionLoader.loadExtensionFromPkgName(ctx, animeextension.pkgName) }
|
||||
}
|
||||
.map { it.await() }
|
||||
.forEach { result ->
|
||||
if (result is AnimeLoadResult.Success) {
|
||||
registerNewExtension(result.extension)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the given anime extension in this and the source managers.
|
||||
*
|
||||
* @param extension The anime extension to be registered.
|
||||
*/
|
||||
private fun registerNewExtension(extension: AnimeExtension.Installed) {
|
||||
_installedAnimeExtensionsFlow.value += extension
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the given updated anime extension in this and the source managers previously removing
|
||||
* the outdated ones.
|
||||
*
|
||||
* @param extension The anime extension to be registered.
|
||||
*/
|
||||
private fun registerUpdatedExtension(extension: AnimeExtension.Installed) {
|
||||
val mutInstalledAnimeExtensions = _installedAnimeExtensionsFlow.value.toMutableList()
|
||||
val oldAnimeExtension = mutInstalledAnimeExtensions.find { it.pkgName == extension.pkgName }
|
||||
if (oldAnimeExtension != null) {
|
||||
mutInstalledAnimeExtensions -= oldAnimeExtension
|
||||
}
|
||||
mutInstalledAnimeExtensions += extension
|
||||
_installedAnimeExtensionsFlow.value = mutInstalledAnimeExtensions
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters the animeextension in this and the source managers given its package name. Note this
|
||||
* method is called for every uninstalled application in the system.
|
||||
*
|
||||
* @param pkgName The package name of the uninstalled application.
|
||||
*/
|
||||
private fun unregisterAnimeExtension(pkgName: String) {
|
||||
val installedAnimeExtension = _installedAnimeExtensionsFlow.value.find { it.pkgName == pkgName }
|
||||
if (installedAnimeExtension != null) {
|
||||
_installedAnimeExtensionsFlow.value -= installedAnimeExtension
|
||||
}
|
||||
val untrustedAnimeExtension = _untrustedAnimeExtensionsFlow.value.find { it.pkgName == pkgName }
|
||||
if (untrustedAnimeExtension != null) {
|
||||
_untrustedAnimeExtensionsFlow.value -= untrustedAnimeExtension
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Listener which receives events of the anime extensions being installed, updated or removed.
|
||||
*/
|
||||
private inner class AnimeInstallationListener : AnimeExtensionInstallReceiver.Listener {
|
||||
|
||||
override fun onExtensionInstalled(extension: AnimeExtension.Installed) {
|
||||
registerNewExtension(extension.withUpdateCheck())
|
||||
updatePendingUpdatesCount()
|
||||
}
|
||||
|
||||
override fun onExtensionUpdated(extension: AnimeExtension.Installed) {
|
||||
registerUpdatedExtension(extension.withUpdateCheck())
|
||||
updatePendingUpdatesCount()
|
||||
}
|
||||
|
||||
override fun onExtensionUntrusted(extension: AnimeExtension.Untrusted) {
|
||||
_untrustedAnimeExtensionsFlow.value += extension
|
||||
}
|
||||
|
||||
override fun onPackageUninstalled(pkgName: String) {
|
||||
unregisterAnimeExtension(pkgName)
|
||||
updatePendingUpdatesCount()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AnimeExtension method to set the update field of an installed anime extension.
|
||||
*/
|
||||
private fun AnimeExtension.Installed.withUpdateCheck(): AnimeExtension.Installed {
|
||||
return if (updateExists()) {
|
||||
copy(hasUpdate = true)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
private fun AnimeExtension.Installed.updateExists(availableAnimeExtension: AnimeExtension.Available? = null): Boolean {
|
||||
val availableExt = availableAnimeExtension ?: _availableAnimeExtensionsFlow.value.find { it.pkgName == pkgName }
|
||||
if (isUnofficial || availableExt == null) return false
|
||||
|
||||
return (availableExt.versionCode > versionCode || availableExt.libVersion > libVersion)
|
||||
}
|
||||
|
||||
private fun updatePendingUpdatesCount() {
|
||||
//preferences.animeExtensionUpdatesCount().set(_installedAnimeExtensionsFlow.value.count { it.hasUpdate })
|
||||
}
|
||||
}
|
|
@ -0,0 +1,187 @@
|
|||
package ani.dantotsu.aniyomi.anime.api
|
||||
|
||||
import android.content.Context
|
||||
import ani.dantotsu.aniyomi.util.extension.ExtensionUpdateNotifier
|
||||
import ani.dantotsu.aniyomi.anime.AnimeExtensionManager
|
||||
import ani.dantotsu.aniyomi.anime.model.AnimeExtension
|
||||
import ani.dantotsu.aniyomi.anime.model.AnimeLoadResult
|
||||
import ani.dantotsu.aniyomi.anime.model.AvailableAnimeSources
|
||||
import ani.dantotsu.aniyomi.anime.util.AnimeExtensionLoader
|
||||
import ani.dantotsu.aniyomi.core.preference.PreferenceStore
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||
import eu.kanade.tachiyomi.network.awaitSuccess
|
||||
import eu.kanade.tachiyomi.network.parseAs
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import logcat.LogPriority
|
||||
//import ani.dantotsu.aniyomi.core.preference.Preference
|
||||
//import ani.dantotsu.aniyomi.core.preference.PreferenceStore
|
||||
import ani.dantotsu.aniyomi.util.withIOContext
|
||||
import ani.dantotsu.aniyomi.util.logcat
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
internal class AnimeExtensionGithubApi {
|
||||
|
||||
private val networkService: NetworkHelper by injectLazy()
|
||||
private val preferenceStore: PreferenceStore by injectLazy()
|
||||
private val animeExtensionManager: AnimeExtensionManager by injectLazy()
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
//private val lastExtCheck: Preference<Long> by lazy {
|
||||
// preferenceStore.getLong("last_ext_check", 0)
|
||||
//}
|
||||
private val lastExtCheck: Long = 0
|
||||
|
||||
private var requiresFallbackSource = false
|
||||
|
||||
suspend fun findExtensions(): List<AnimeExtension.Available> {
|
||||
return withIOContext {
|
||||
val githubResponse = if (requiresFallbackSource) {
|
||||
null
|
||||
} else {
|
||||
try {
|
||||
networkService.client
|
||||
.newCall(GET("${REPO_URL_PREFIX}index.min.json"))
|
||||
.awaitSuccess()
|
||||
} catch (e: Throwable) {
|
||||
logcat(LogPriority.ERROR, e) { "Failed to get extensions from GitHub" }
|
||||
requiresFallbackSource = true
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val response = githubResponse ?: run {
|
||||
networkService.client
|
||||
.newCall(GET("${FALLBACK_REPO_URL_PREFIX}index.min.json"))
|
||||
.awaitSuccess()
|
||||
}
|
||||
|
||||
val extensions = with(json) {
|
||||
response
|
||||
.parseAs<List<AnimeExtensionJsonObject>>()
|
||||
.toExtensions()
|
||||
}
|
||||
|
||||
// Sanity check - a small number of extensions probably means something broke
|
||||
// with the repo generator
|
||||
if (extensions.size < 10) {
|
||||
throw Exception()
|
||||
}
|
||||
|
||||
extensions
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun checkForUpdates(context: Context, fromAvailableExtensionList: Boolean = false): List<AnimeExtension.Installed>? {
|
||||
// Limit checks to once a day at most
|
||||
//if (fromAvailableExtensionList && Date().time < lastExtCheck.get() + 1.days.inWholeMilliseconds) {
|
||||
// return null
|
||||
//}
|
||||
|
||||
val extensions = if (fromAvailableExtensionList) {
|
||||
animeExtensionManager.availableExtensionsFlow.value
|
||||
} else {
|
||||
findExtensions().also { }//lastExtCheck.set(Date().time) }
|
||||
}
|
||||
|
||||
val installedExtensions = AnimeExtensionLoader.loadExtensions(context)
|
||||
.filterIsInstance<AnimeLoadResult.Success>()
|
||||
.map { it.extension }
|
||||
|
||||
val extensionsWithUpdate = mutableListOf<AnimeExtension.Installed>()
|
||||
for (installedExt in installedExtensions) {
|
||||
val pkgName = installedExt.pkgName
|
||||
val availableExt = extensions.find { it.pkgName == pkgName } ?: continue
|
||||
|
||||
val hasUpdatedVer = availableExt.versionCode > installedExt.versionCode
|
||||
val hasUpdatedLib = availableExt.libVersion > installedExt.libVersion
|
||||
val hasUpdate = installedExt.isUnofficial.not() && (hasUpdatedVer || hasUpdatedLib)
|
||||
if (hasUpdate) {
|
||||
extensionsWithUpdate.add(installedExt)
|
||||
}
|
||||
}
|
||||
|
||||
if (extensionsWithUpdate.isNotEmpty()) {
|
||||
ExtensionUpdateNotifier(context).promptUpdates(extensionsWithUpdate.map { it.name })
|
||||
}
|
||||
|
||||
return extensionsWithUpdate
|
||||
}
|
||||
|
||||
private fun List<AnimeExtensionJsonObject>.toExtensions(): List<AnimeExtension.Available> {
|
||||
return this
|
||||
.filter {
|
||||
val libVersion = it.extractLibVersion()
|
||||
libVersion >= AnimeExtensionLoader.LIB_VERSION_MIN && libVersion <= AnimeExtensionLoader.LIB_VERSION_MAX
|
||||
}
|
||||
.map {
|
||||
AnimeExtension.Available(
|
||||
name = it.name.substringAfter("Aniyomi: "),
|
||||
pkgName = it.pkg,
|
||||
versionName = it.version,
|
||||
versionCode = it.code,
|
||||
libVersion = it.extractLibVersion(),
|
||||
lang = it.lang,
|
||||
isNsfw = it.nsfw == 1,
|
||||
hasReadme = it.hasReadme == 1,
|
||||
hasChangelog = it.hasChangelog == 1,
|
||||
sources = it.sources?.toAnimeExtensionSources().orEmpty(),
|
||||
apkName = it.apk,
|
||||
iconUrl = "${getUrlPrefix()}icon/${it.apk.replace(".apk", ".png")}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<AnimeExtensionSourceJsonObject>.toAnimeExtensionSources(): List<AvailableAnimeSources> {
|
||||
return this.map {
|
||||
AvailableAnimeSources(
|
||||
id = it.id,
|
||||
lang = it.lang,
|
||||
name = it.name,
|
||||
baseUrl = it.baseUrl,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getApkUrl(extension: AnimeExtension.Available): String {
|
||||
return "${getUrlPrefix()}apk/${extension.apkName}"
|
||||
}
|
||||
|
||||
private fun getUrlPrefix(): String {
|
||||
return if (requiresFallbackSource) {
|
||||
FALLBACK_REPO_URL_PREFIX
|
||||
} else {
|
||||
REPO_URL_PREFIX
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun AnimeExtensionJsonObject.extractLibVersion(): Double {
|
||||
return version.substringBeforeLast('.').toDouble()
|
||||
}
|
||||
|
||||
private const val REPO_URL_PREFIX = "https://raw.githubusercontent.com/aniyomiorg/aniyomi-extensions/repo/"
|
||||
private const val FALLBACK_REPO_URL_PREFIX = "https://gcore.jsdelivr.net/gh/aniyomiorg/aniyomi-extensions@repo/"
|
||||
|
||||
@Serializable
|
||||
private data class AnimeExtensionJsonObject(
|
||||
val name: String,
|
||||
val pkg: String,
|
||||
val apk: String,
|
||||
val lang: String,
|
||||
val code: Long,
|
||||
val version: String,
|
||||
val nsfw: Int,
|
||||
val hasReadme: Int = 0,
|
||||
val hasChangelog: Int = 0,
|
||||
val sources: List<AnimeExtensionSourceJsonObject>?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class AnimeExtensionSourceJsonObject(
|
||||
val id: Long,
|
||||
val lang: String,
|
||||
val name: String,
|
||||
val baseUrl: String,
|
||||
)
|
30
app/src/main/java/ani/dantotsu/aniyomi/anime/custom/App.kt
Normal file
30
app/src/main/java/ani/dantotsu/aniyomi/anime/custom/App.kt
Normal file
|
@ -0,0 +1,30 @@
|
|||
package ani.dantotsu.aniyomi.anime.custom
|
||||
/*
|
||||
import android.app.Application
|
||||
import ani.dantotsu.aniyomi.data.Notifications
|
||||
import ani.dantotsu.aniyomi.util.logcat
|
||||
import logcat.AndroidLogcatLogger
|
||||
import logcat.LogPriority
|
||||
import logcat.LogcatLogger
|
||||
import uy.kohesive.injekt.Injekt
|
||||
|
||||
class App : Application() {
|
||||
override fun onCreate() {
|
||||
super<Application>.onCreate()
|
||||
Injekt.importModule(AppModule(this))
|
||||
Injekt.importModule(PreferenceModule(this))
|
||||
|
||||
setupNotificationChannels()
|
||||
if (!LogcatLogger.isInstalled) {
|
||||
LogcatLogger.install(AndroidLogcatLogger(LogPriority.VERBOSE))
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupNotificationChannels() {
|
||||
try {
|
||||
Notifications.createChannels(this)
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR, e) { "Failed to modify notification channels" }
|
||||
}
|
||||
}
|
||||
}*/
|
|
@ -0,0 +1,48 @@
|
|||
package ani.dantotsu.aniyomi.anime.custom
|
||||
|
||||
import android.app.Application
|
||||
import ani.dantotsu.aniyomi.anime.AnimeExtensionManager
|
||||
import ani.dantotsu.aniyomi.core.preference.PreferenceStore
|
||||
import ani.dantotsu.aniyomi.domain.base.BasePreferences
|
||||
import ani.dantotsu.aniyomi.domain.source.service.SourcePreferences
|
||||
import ani.dantotsu.aniyomi.core.preference.AndroidPreferenceStore
|
||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||
import kotlinx.serialization.json.Json
|
||||
import uy.kohesive.injekt.api.InjektModule
|
||||
import uy.kohesive.injekt.api.InjektRegistrar
|
||||
import uy.kohesive.injekt.api.addSingleton
|
||||
import uy.kohesive.injekt.api.addSingletonFactory
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
class AppModule(val app: Application) : InjektModule {
|
||||
override fun InjektRegistrar.registerInjectables() {
|
||||
addSingleton(app)
|
||||
|
||||
addSingletonFactory { NetworkHelper(app) }
|
||||
|
||||
addSingletonFactory { AnimeExtensionManager(app) }
|
||||
|
||||
addSingletonFactory {
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PreferenceModule(val application: Application) : InjektModule {
|
||||
override fun InjektRegistrar.registerInjectables() {
|
||||
addSingletonFactory<PreferenceStore> {
|
||||
AndroidPreferenceStore(application)
|
||||
}
|
||||
|
||||
addSingletonFactory {
|
||||
SourcePreferences(get())
|
||||
}
|
||||
|
||||
addSingletonFactory {
|
||||
BasePreferences(application, get())
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,170 @@
|
|||
package ani.dantotsu.aniyomi.anime.installer
|
||||
|
||||
import android.app.Service
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.Uri
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import ani.dantotsu.aniyomi.util.extension.InstallStep
|
||||
import ani.dantotsu.aniyomi.anime.AnimeExtensionManager
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* Base implementation class for extension installer. To be used inside a foreground [Service].
|
||||
*/
|
||||
abstract class InstallerAnime(private val service: Service) {
|
||||
|
||||
private val extensionManager: AnimeExtensionManager by injectLazy()
|
||||
|
||||
private var waitingInstall = AtomicReference<Entry>(null)
|
||||
private val queue = Collections.synchronizedList(mutableListOf<Entry>())
|
||||
|
||||
private val cancelReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val downloadId = intent.getLongExtra(EXTRA_DOWNLOAD_ID, -1).takeIf { it >= 0 } ?: return
|
||||
cancelQueue(downloadId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Installer readiness. If false, queue check will not run.
|
||||
*
|
||||
* @see checkQueue
|
||||
*/
|
||||
abstract var ready: Boolean
|
||||
|
||||
/**
|
||||
* Add an item to install queue.
|
||||
*
|
||||
* @param downloadId Download ID as known by [ExtensionManager]
|
||||
* @param uri Uri of APK to install
|
||||
*/
|
||||
fun addToQueue(downloadId: Long, uri: Uri) {
|
||||
queue.add(Entry(downloadId, uri))
|
||||
checkQueue()
|
||||
}
|
||||
|
||||
/**
|
||||
* Proceeds to install the APK of this entry inside this method. Call [continueQueue]
|
||||
* when the install process for this entry is finished to continue the queue.
|
||||
*
|
||||
* @param entry The [Entry] of item to process
|
||||
* @see continueQueue
|
||||
*/
|
||||
@CallSuper
|
||||
open fun processEntry(entry: Entry) {
|
||||
extensionManager.setInstalling(entry.downloadId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called before queue continues. Override this to handle when the removed entry is
|
||||
* currently being processed.
|
||||
*
|
||||
* @return true if this entry can be removed from queue.
|
||||
*/
|
||||
open fun cancelEntry(entry: Entry): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the queue to continue processing the next entry and updates the install step
|
||||
* of the completed entry ([waitingInstall]) to [ExtensionManager].
|
||||
*
|
||||
* @param resultStep new install step for the processed entry.
|
||||
* @see waitingInstall
|
||||
*/
|
||||
fun continueQueue(resultStep: InstallStep) {
|
||||
val completedEntry = waitingInstall.getAndSet(null)
|
||||
if (completedEntry != null) {
|
||||
extensionManager.updateInstallStep(completedEntry.downloadId, resultStep)
|
||||
checkQueue()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the queue. The provided service will be stopped if the queue is empty.
|
||||
* Will not be run when not ready.
|
||||
*
|
||||
* @see ready
|
||||
*/
|
||||
fun checkQueue() {
|
||||
if (!ready) {
|
||||
return
|
||||
}
|
||||
if (queue.isEmpty()) {
|
||||
service.stopSelf()
|
||||
return
|
||||
}
|
||||
val nextEntry = queue.first()
|
||||
if (waitingInstall.compareAndSet(null, nextEntry)) {
|
||||
queue.removeFirst()
|
||||
processEntry(nextEntry)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call this method when the provided service is destroyed.
|
||||
*/
|
||||
@CallSuper
|
||||
open fun onDestroy() {
|
||||
LocalBroadcastManager.getInstance(service).unregisterReceiver(cancelReceiver)
|
||||
queue.forEach { extensionManager.updateInstallStep(it.downloadId, InstallStep.Error) }
|
||||
queue.clear()
|
||||
waitingInstall.set(null)
|
||||
}
|
||||
|
||||
protected fun getActiveEntry(): Entry? = waitingInstall.get()
|
||||
|
||||
/**
|
||||
* Cancels queue for the provided download ID if exists.
|
||||
*
|
||||
* @param downloadId Download ID as known by [ExtensionManager]
|
||||
*/
|
||||
private fun cancelQueue(downloadId: Long) {
|
||||
val waitingInstall = this.waitingInstall.get()
|
||||
val toCancel = queue.find { it.downloadId == downloadId } ?: waitingInstall ?: return
|
||||
if (cancelEntry(toCancel)) {
|
||||
queue.remove(toCancel)
|
||||
if (waitingInstall == toCancel) {
|
||||
// Currently processing removed entry, continue queue
|
||||
this.waitingInstall.set(null)
|
||||
checkQueue()
|
||||
}
|
||||
extensionManager.updateInstallStep(downloadId, InstallStep.Idle)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install item to queue.
|
||||
*
|
||||
* @param downloadId Download ID as known by [ExtensionManager]
|
||||
* @param uri Uri of APK to install
|
||||
*/
|
||||
data class Entry(val downloadId: Long, val uri: Uri)
|
||||
|
||||
init {
|
||||
val filter = IntentFilter(ACTION_CANCEL_QUEUE)
|
||||
LocalBroadcastManager.getInstance(service).registerReceiver(cancelReceiver, filter)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ACTION_CANCEL_QUEUE = "InstallerAnime.action.CANCEL_QUEUE"
|
||||
private const val EXTRA_DOWNLOAD_ID = "InstallerAnime.extra.DOWNLOAD_ID"
|
||||
|
||||
/**
|
||||
* Attempts to cancel the installation entry for the provided download ID.
|
||||
*
|
||||
* @param downloadId Download ID as known by [ExtensionManager]
|
||||
*/
|
||||
fun cancelInstallQueue(context: Context, downloadId: Long) {
|
||||
val intent = Intent(ACTION_CANCEL_QUEUE)
|
||||
intent.putExtra(EXTRA_DOWNLOAD_ID, downloadId)
|
||||
LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,107 @@
|
|||
package ani.dantotsu.aniyomi.anime.installer
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.os.Build
|
||||
import ani.dantotsu.aniyomi.util.extension.InstallStep
|
||||
import ani.dantotsu.aniyomi.util.lang.use
|
||||
import ani.dantotsu.aniyomi.util.system.getParcelableExtraCompat
|
||||
import ani.dantotsu.aniyomi.util.system.getUriSize
|
||||
import logcat.LogPriority
|
||||
import ani.dantotsu.aniyomi.util.logcat
|
||||
|
||||
class PackageInstallerInstallerAnime(private val service: Service) : InstallerAnime(service) {
|
||||
|
||||
private val packageInstaller = service.packageManager.packageInstaller
|
||||
|
||||
private val packageActionReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)) {
|
||||
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
|
||||
val userAction = intent.getParcelableExtraCompat<Intent>(Intent.EXTRA_INTENT)
|
||||
if (userAction == null) {
|
||||
logcat(LogPriority.ERROR) { "Fatal error for $intent" }
|
||||
continueQueue(InstallStep.Error)
|
||||
return
|
||||
}
|
||||
userAction.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
service.startActivity(userAction)
|
||||
}
|
||||
PackageInstaller.STATUS_FAILURE_ABORTED -> {
|
||||
continueQueue(InstallStep.Idle)
|
||||
}
|
||||
PackageInstaller.STATUS_SUCCESS -> continueQueue(InstallStep.Installed)
|
||||
else -> continueQueue(InstallStep.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var activeSession: Pair<Entry, Int>? = null
|
||||
|
||||
// Always ready
|
||||
override var ready = true
|
||||
|
||||
override fun processEntry(entry: Entry) {
|
||||
super.processEntry(entry)
|
||||
activeSession = null
|
||||
try {
|
||||
val installParams = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
installParams.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
|
||||
}
|
||||
activeSession = entry to packageInstaller.createSession(installParams)
|
||||
val fileSize = service.getUriSize(entry.uri) ?: throw IllegalStateException()
|
||||
installParams.setSize(fileSize)
|
||||
|
||||
val inputStream = service.contentResolver.openInputStream(entry.uri) ?: throw IllegalStateException()
|
||||
val session = packageInstaller.openSession(activeSession!!.second)
|
||||
val outputStream = session.openWrite(entry.downloadId.toString(), 0, fileSize)
|
||||
session.use {
|
||||
arrayOf(inputStream, outputStream).use {
|
||||
inputStream.copyTo(outputStream)
|
||||
session.fsync(outputStream)
|
||||
}
|
||||
|
||||
val intentSender = PendingIntent.getBroadcast(
|
||||
service,
|
||||
activeSession!!.second,
|
||||
Intent(INSTALL_ACTION),
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0,
|
||||
).intentSender
|
||||
session.commit(intentSender)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR, e) { "Failed to install extension ${entry.downloadId} ${entry.uri}" }
|
||||
activeSession?.let { (_, sessionId) ->
|
||||
packageInstaller.abandonSession(sessionId)
|
||||
}
|
||||
continueQueue(InstallStep.Error)
|
||||
}
|
||||
}
|
||||
|
||||
override fun cancelEntry(entry: Entry): Boolean {
|
||||
activeSession?.let { (activeEntry, sessionId) ->
|
||||
if (activeEntry == entry) {
|
||||
packageInstaller.abandonSession(sessionId)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
service.unregisterReceiver(packageActionReceiver)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
init {
|
||||
service.registerReceiver(packageActionReceiver, IntentFilter(INSTALL_ACTION))
|
||||
}
|
||||
}
|
||||
|
||||
private const val INSTALL_ACTION = "PackageInstallerInstaller.INSTALL_ACTION"
|
|
@ -0,0 +1,79 @@
|
|||
package ani.dantotsu.aniyomi.anime.model
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
import ani.dantotsu.aniyomi.animesource.AnimeSource
|
||||
import ani.dantotsu.aniyomi.domain.source.anime.model.AnimeSourceData
|
||||
|
||||
sealed class AnimeExtension {
|
||||
|
||||
abstract val name: String
|
||||
abstract val pkgName: String
|
||||
abstract val versionName: String
|
||||
abstract val versionCode: Long
|
||||
abstract val libVersion: Double
|
||||
abstract val lang: String?
|
||||
abstract val isNsfw: Boolean
|
||||
abstract val hasReadme: Boolean
|
||||
abstract val hasChangelog: Boolean
|
||||
|
||||
data class Installed(
|
||||
override val name: String,
|
||||
override val pkgName: String,
|
||||
override val versionName: String,
|
||||
override val versionCode: Long,
|
||||
override val libVersion: Double,
|
||||
override val lang: String,
|
||||
override val isNsfw: Boolean,
|
||||
override val hasReadme: Boolean,
|
||||
override val hasChangelog: Boolean,
|
||||
val pkgFactory: String?,
|
||||
val sources: List<AnimeSource>,
|
||||
val icon: Drawable?,
|
||||
val hasUpdate: Boolean = false,
|
||||
val isObsolete: Boolean = false,
|
||||
val isUnofficial: Boolean = false,
|
||||
) : AnimeExtension()
|
||||
|
||||
data class Available(
|
||||
override val name: String,
|
||||
override val pkgName: String,
|
||||
override val versionName: String,
|
||||
override val versionCode: Long,
|
||||
override val libVersion: Double,
|
||||
override val lang: String,
|
||||
override val isNsfw: Boolean,
|
||||
override val hasReadme: Boolean,
|
||||
override val hasChangelog: Boolean,
|
||||
val sources: List<AvailableAnimeSources>,
|
||||
val apkName: String,
|
||||
val iconUrl: String,
|
||||
) : AnimeExtension()
|
||||
|
||||
data class Untrusted(
|
||||
override val name: String,
|
||||
override val pkgName: String,
|
||||
override val versionName: String,
|
||||
override val versionCode: Long,
|
||||
override val libVersion: Double,
|
||||
val signatureHash: String,
|
||||
override val lang: String? = null,
|
||||
override val isNsfw: Boolean = false,
|
||||
override val hasReadme: Boolean = false,
|
||||
override val hasChangelog: Boolean = false,
|
||||
) : AnimeExtension()
|
||||
}
|
||||
|
||||
data class AvailableAnimeSources(
|
||||
val id: Long,
|
||||
val lang: String,
|
||||
val name: String,
|
||||
val baseUrl: String,
|
||||
) {
|
||||
fun toAnimeSourceData(): AnimeSourceData {
|
||||
return AnimeSourceData(
|
||||
id = this.id,
|
||||
lang = this.lang,
|
||||
name = this.name,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
package ani.dantotsu.aniyomi.anime.model
|
||||
|
||||
sealed class AnimeLoadResult {
|
||||
class Success(val extension: AnimeExtension.Installed) : AnimeLoadResult()
|
||||
class Untrusted(val extension: AnimeExtension.Untrusted) : AnimeLoadResult()
|
||||
object Error : AnimeLoadResult()
|
||||
}
|
|
@ -0,0 +1,78 @@
|
|||
package ani.dantotsu.aniyomi.anime.util
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import ani.dantotsu.aniyomi.util.extension.InstallStep
|
||||
import ani.dantotsu.aniyomi.anime.AnimeExtensionManager
|
||||
import ani.dantotsu.aniyomi.util.system.hasMiuiPackageInstaller
|
||||
import ani.dantotsu.aniyomi.util.toast
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Activity used to install extensions, because we can only receive the result of the installation
|
||||
* with [startActivityForResult], which we need to update the UI.
|
||||
*/
|
||||
class AnimeExtensionInstallActivity : Activity() {
|
||||
|
||||
// MIUI package installer bug workaround
|
||||
private var ignoreUntil = 0L
|
||||
private var ignoreResult = false
|
||||
private var hasIgnoredResult = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val installIntent = Intent(Intent.ACTION_INSTALL_PACKAGE)
|
||||
.setDataAndType(intent.data, intent.type)
|
||||
.putExtra(Intent.EXTRA_RETURN_RESULT, true)
|
||||
.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
|
||||
if (hasMiuiPackageInstaller) {
|
||||
ignoreResult = true
|
||||
ignoreUntil = System.nanoTime() + 1.seconds.inWholeNanoseconds
|
||||
}
|
||||
|
||||
try {
|
||||
startActivityForResult(installIntent, INSTALL_REQUEST_CODE)
|
||||
} catch (error: Exception) {
|
||||
// Either install package can't be found (probably bots) or there's a security exception
|
||||
// with the download manager. Nothing we can workaround.
|
||||
toast(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
if (ignoreResult && System.nanoTime() < ignoreUntil) {
|
||||
hasIgnoredResult = true
|
||||
return
|
||||
}
|
||||
if (requestCode == INSTALL_REQUEST_CODE) {
|
||||
checkInstallationResult(resultCode)
|
||||
}
|
||||
finish()
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
if (hasIgnoredResult) {
|
||||
checkInstallationResult(RESULT_CANCELED)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkInstallationResult(resultCode: Int) {
|
||||
val downloadId = intent.extras!!.getLong(AnimeExtensionInstaller.EXTRA_DOWNLOAD_ID)
|
||||
val extensionManager = Injekt.get<AnimeExtensionManager>()
|
||||
val newStep = when (resultCode) {
|
||||
RESULT_OK -> InstallStep.Installed
|
||||
RESULT_CANCELED -> InstallStep.Idle
|
||||
else -> InstallStep.Error
|
||||
}
|
||||
extensionManager.updateInstallStep(downloadId, newStep)
|
||||
}
|
||||
}
|
||||
|
||||
private const val INSTALL_REQUEST_CODE = 500
|
|
@ -0,0 +1,130 @@
|
|||
package ani.dantotsu.aniyomi.anime.util
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import ani.dantotsu.aniyomi.anime.model.AnimeExtension
|
||||
import ani.dantotsu.aniyomi.anime.model.AnimeLoadResult
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.async
|
||||
import logcat.LogPriority
|
||||
import ani.dantotsu.aniyomi.util.launchNow
|
||||
import ani.dantotsu.aniyomi.util.logcat
|
||||
|
||||
/**
|
||||
* Broadcast receiver that listens for the system's packages installed, updated or removed, and only
|
||||
* notifies the given [listener] when the package is an extension.
|
||||
*
|
||||
* @param listener The listener that should be notified of extension installation events.
|
||||
*/
|
||||
internal class AnimeExtensionInstallReceiver(private val listener: Listener) :
|
||||
BroadcastReceiver() {
|
||||
|
||||
/**
|
||||
* Registers this broadcast receiver
|
||||
*/
|
||||
fun register(context: Context) {
|
||||
context.registerReceiver(this, filter)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the intent filter this receiver should subscribe to.
|
||||
*/
|
||||
private val filter
|
||||
get() = IntentFilter().apply {
|
||||
addAction(Intent.ACTION_PACKAGE_ADDED)
|
||||
addAction(Intent.ACTION_PACKAGE_REPLACED)
|
||||
addAction(Intent.ACTION_PACKAGE_REMOVED)
|
||||
addDataScheme("package")
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when one of the events of the [filter] is received. When the package is an extension,
|
||||
* it's loaded in background and it notifies the [listener] when finished.
|
||||
*/
|
||||
override fun onReceive(context: Context, intent: Intent?) {
|
||||
if (intent == null) return
|
||||
|
||||
when (intent.action) {
|
||||
Intent.ACTION_PACKAGE_ADDED -> {
|
||||
if (isReplacing(intent)) return
|
||||
|
||||
launchNow {
|
||||
when (val result = getExtensionFromIntent(context, intent)) {
|
||||
is AnimeLoadResult.Success -> listener.onExtensionInstalled(result.extension)
|
||||
|
||||
is AnimeLoadResult.Untrusted -> listener.onExtensionUntrusted(result.extension)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Intent.ACTION_PACKAGE_REPLACED -> {
|
||||
launchNow {
|
||||
when (val result = getExtensionFromIntent(context, intent)) {
|
||||
is AnimeLoadResult.Success -> listener.onExtensionUpdated(result.extension)
|
||||
// Not needed as a package can't be upgraded if the signature is different
|
||||
// is LoadResult.Untrusted -> {}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Intent.ACTION_PACKAGE_REMOVED -> {
|
||||
if (isReplacing(intent)) return
|
||||
|
||||
val pkgName = getPackageNameFromIntent(intent)
|
||||
if (pkgName != null) {
|
||||
listener.onPackageUninstalled(pkgName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this package is performing an update.
|
||||
*
|
||||
* @param intent The intent that triggered the event.
|
||||
*/
|
||||
private fun isReplacing(intent: Intent): Boolean {
|
||||
return intent.getBooleanExtra(Intent.EXTRA_REPLACING, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the extension triggered by the given intent.
|
||||
*
|
||||
* @param context The application context.
|
||||
* @param intent The intent containing the package name of the extension.
|
||||
*/
|
||||
private suspend fun getExtensionFromIntent(context: Context, intent: Intent?): AnimeLoadResult {
|
||||
val pkgName = getPackageNameFromIntent(intent)
|
||||
if (pkgName == null) {
|
||||
logcat(LogPriority.WARN) { "Package name not found" }
|
||||
return AnimeLoadResult.Error
|
||||
}
|
||||
return GlobalScope.async(Dispatchers.Default, CoroutineStart.DEFAULT) {
|
||||
AnimeExtensionLoader.loadExtensionFromPkgName(
|
||||
context,
|
||||
pkgName,
|
||||
)
|
||||
}.await()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the package name of the installed, updated or removed application.
|
||||
*/
|
||||
private fun getPackageNameFromIntent(intent: Intent?): String? {
|
||||
return intent?.data?.encodedSchemeSpecificPart ?: return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Listener that receives extension installation events.
|
||||
*/
|
||||
interface Listener {
|
||||
fun onExtensionInstalled(extension: AnimeExtension.Installed)
|
||||
fun onExtensionUpdated(extension: AnimeExtension.Installed)
|
||||
fun onExtensionUntrusted(extension: AnimeExtension.Untrusted)
|
||||
fun onPackageUninstalled(pkgName: String)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,82 @@
|
|||
package ani.dantotsu.aniyomi.anime.util
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.IBinder
|
||||
import ani.dantotsu.R
|
||||
import ani.dantotsu.aniyomi.domain.base.BasePreferences
|
||||
import ani.dantotsu.aniyomi.data.Notifications
|
||||
import ani.dantotsu.aniyomi.anime.installer.InstallerAnime
|
||||
import ani.dantotsu.aniyomi.anime.installer.PackageInstallerInstallerAnime
|
||||
import ani.dantotsu.aniyomi.anime.util.AnimeExtensionInstaller.Companion.EXTRA_DOWNLOAD_ID
|
||||
import ani.dantotsu.aniyomi.util.system.getSerializableExtraCompat
|
||||
import ani.dantotsu.aniyomi.util.system.notificationBuilder
|
||||
import logcat.LogPriority
|
||||
import ani.dantotsu.aniyomi.util.logcat
|
||||
|
||||
class AnimeExtensionInstallService : Service() {
|
||||
|
||||
private var installer: InstallerAnime? = null
|
||||
|
||||
override fun onCreate() {
|
||||
val notification = notificationBuilder(Notifications.CHANNEL_EXTENSIONS_UPDATE) {
|
||||
setSmallIcon(R.drawable.spinner_icon)
|
||||
setAutoCancel(false)
|
||||
setOngoing(true)
|
||||
setShowWhen(false)
|
||||
setContentTitle("Installing Anime Extension...")
|
||||
setProgress(100, 100, true)
|
||||
}.build()
|
||||
startForeground(Notifications.ID_EXTENSION_INSTALLER, notification)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val uri = intent?.data
|
||||
val id = intent?.getLongExtra(EXTRA_DOWNLOAD_ID, -1)?.takeIf { it != -1L }
|
||||
val installerUsed = intent?.getSerializableExtraCompat<BasePreferences.ExtensionInstaller>(
|
||||
EXTRA_INSTALLER,
|
||||
)
|
||||
if (uri == null || id == null || installerUsed == null) {
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
if (installer == null) {
|
||||
installer = when (installerUsed) {
|
||||
BasePreferences.ExtensionInstaller.PACKAGEINSTALLER -> PackageInstallerInstallerAnime(this)
|
||||
else -> {
|
||||
logcat(LogPriority.ERROR) { "Not implemented for installer $installerUsed" }
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
}
|
||||
}
|
||||
installer!!.addToQueue(id, uri)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
installer?.onDestroy()
|
||||
installer = null
|
||||
}
|
||||
|
||||
override fun onBind(i: Intent?): IBinder? = null
|
||||
|
||||
companion object {
|
||||
private const val EXTRA_INSTALLER = "EXTRA_INSTALLER"
|
||||
|
||||
fun getIntent(
|
||||
context: Context,
|
||||
downloadId: Long,
|
||||
uri: Uri,
|
||||
installer: BasePreferences.ExtensionInstaller,
|
||||
): Intent {
|
||||
return Intent(context, AnimeExtensionInstallService::class.java)
|
||||
.setDataAndType(uri, AnimeExtensionInstaller.APK_MIME)
|
||||
.putExtra(EXTRA_DOWNLOAD_ID, downloadId)
|
||||
.putExtra(EXTRA_INSTALLER, installer)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,266 @@
|
|||
package ani.dantotsu.aniyomi.anime.util
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.net.toUri
|
||||
import com.jakewharton.rxrelay.PublishRelay
|
||||
import ani.dantotsu.aniyomi.util.extension.InstallStep
|
||||
import ani.dantotsu.aniyomi.anime.installer.InstallerAnime
|
||||
import ani.dantotsu.aniyomi.anime.model.AnimeExtension
|
||||
import ani.dantotsu.aniyomi.domain.base.BasePreferences
|
||||
import ani.dantotsu.aniyomi.util.storage.getUriCompat
|
||||
import logcat.LogPriority
|
||||
import rx.Observable
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import ani.dantotsu.aniyomi.util.logcat
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* The installer which installs, updates and uninstalls the extensions.
|
||||
*
|
||||
* @param context The application context.
|
||||
*/
|
||||
internal class AnimeExtensionInstaller(private val context: Context) {
|
||||
|
||||
/**
|
||||
* The system's download manager
|
||||
*/
|
||||
private val downloadManager = context.getSystemService<DownloadManager>()!!
|
||||
|
||||
/**
|
||||
* The broadcast receiver which listens to download completion events.
|
||||
*/
|
||||
private val downloadReceiver = DownloadCompletionReceiver()
|
||||
|
||||
/**
|
||||
* The currently requested downloads, with the package name (unique id) as key, and the id
|
||||
* returned by the download manager.
|
||||
*/
|
||||
private val activeDownloads = hashMapOf<String, Long>()
|
||||
|
||||
/**
|
||||
* Relay used to notify the installation step of every download.
|
||||
*/
|
||||
private val downloadsRelay = PublishRelay.create<Pair<Long, InstallStep>>()
|
||||
|
||||
private val extensionInstaller = Injekt.get<BasePreferences>().extensionInstaller()
|
||||
|
||||
/**
|
||||
* Adds the given extension to the downloads queue and returns an observable containing its
|
||||
* step in the installation process.
|
||||
*
|
||||
* @param url The url of the apk.
|
||||
* @param extension The extension to install.
|
||||
*/
|
||||
fun downloadAndInstall(url: String, extension: AnimeExtension) = Observable.defer {
|
||||
val pkgName = extension.pkgName
|
||||
|
||||
val oldDownload = activeDownloads[pkgName]
|
||||
if (oldDownload != null) {
|
||||
deleteDownload(pkgName)
|
||||
}
|
||||
|
||||
// Register the receiver after removing (and unregistering) the previous download
|
||||
downloadReceiver.register()
|
||||
|
||||
val downloadUri = url.toUri()
|
||||
val request = DownloadManager.Request(downloadUri)
|
||||
.setTitle(extension.name)
|
||||
.setMimeType(APK_MIME)
|
||||
.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, downloadUri.lastPathSegment)
|
||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||
|
||||
val id = downloadManager.enqueue(request)
|
||||
activeDownloads[pkgName] = id
|
||||
|
||||
downloadsRelay.filter { it.first == id }
|
||||
.map { it.second }
|
||||
// Poll download status
|
||||
.mergeWith(pollStatus(id))
|
||||
// Stop when the application is installed or errors
|
||||
.takeUntil { it.isCompleted() }
|
||||
// Always notify on main thread
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
// Always remove the download when unsubscribed
|
||||
.doOnUnsubscribe { deleteDownload(pkgName) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable that polls the given download id for its status every second, as the
|
||||
* manager doesn't have any notification system. It'll stop once the download finishes.
|
||||
*
|
||||
* @param id The id of the download to poll.
|
||||
*/
|
||||
private fun pollStatus(id: Long): Observable<InstallStep> {
|
||||
val query = DownloadManager.Query().setFilterById(id)
|
||||
|
||||
return Observable.interval(0, 1, TimeUnit.SECONDS)
|
||||
// Get the current download status
|
||||
.map {
|
||||
downloadManager.query(query).use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
|
||||
}
|
||||
}
|
||||
// Ignore duplicate results
|
||||
.distinctUntilChanged()
|
||||
// Stop polling when the download fails or finishes
|
||||
.takeUntil { it == DownloadManager.STATUS_SUCCESSFUL || it == DownloadManager.STATUS_FAILED }
|
||||
// Map to our model
|
||||
.flatMap { status ->
|
||||
when (status) {
|
||||
DownloadManager.STATUS_PENDING -> Observable.just(InstallStep.Pending)
|
||||
DownloadManager.STATUS_RUNNING -> Observable.just(InstallStep.Downloading)
|
||||
else -> Observable.empty()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts an intent to install the extension at the given uri.
|
||||
*
|
||||
* @param uri The uri of the extension to install.
|
||||
*/
|
||||
fun installApk(downloadId: Long, uri: Uri) {
|
||||
when (val installer = extensionInstaller.get()) {
|
||||
BasePreferences.ExtensionInstaller.LEGACY -> {
|
||||
val intent = Intent(context, AnimeExtensionInstallActivity::class.java)
|
||||
.setDataAndType(uri, APK_MIME)
|
||||
.putExtra(EXTRA_DOWNLOAD_ID, downloadId)
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
|
||||
context.startActivity(intent)
|
||||
}
|
||||
else -> {
|
||||
val intent =
|
||||
AnimeExtensionInstallService.getIntent(context, downloadId, uri, installer)
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels extension install and remove from download manager and installer.
|
||||
*/
|
||||
fun cancelInstall(pkgName: String) {
|
||||
val downloadId = activeDownloads.remove(pkgName) ?: return
|
||||
downloadManager.remove(downloadId)
|
||||
InstallerAnime.cancelInstallQueue(context, downloadId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts an intent to uninstall the extension by the given package name.
|
||||
*
|
||||
* @param pkgName The package name of the extension to uninstall
|
||||
*/
|
||||
fun uninstallApk(pkgName: String) {
|
||||
val intent = Intent(Intent.ACTION_UNINSTALL_PACKAGE, "package:$pkgName".toUri())
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
context.startActivity(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the step of the installation of an extension.
|
||||
*
|
||||
* @param downloadId The id of the download.
|
||||
* @param step New install step.
|
||||
*/
|
||||
fun updateInstallStep(downloadId: Long, step: InstallStep) {
|
||||
downloadsRelay.call(downloadId to step)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the download for the given package name.
|
||||
*
|
||||
* @param pkgName The package name of the download to delete.
|
||||
*/
|
||||
private fun deleteDownload(pkgName: String) {
|
||||
val downloadId = activeDownloads.remove(pkgName)
|
||||
if (downloadId != null) {
|
||||
downloadManager.remove(downloadId)
|
||||
}
|
||||
if (activeDownloads.isEmpty()) {
|
||||
downloadReceiver.unregister()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Receiver that listens to download status events.
|
||||
*/
|
||||
private inner class DownloadCompletionReceiver : BroadcastReceiver() {
|
||||
|
||||
/**
|
||||
* Whether this receiver is currently registered.
|
||||
*/
|
||||
private var isRegistered = false
|
||||
|
||||
/**
|
||||
* Registers this receiver if it's not already.
|
||||
*/
|
||||
fun register() {
|
||||
if (isRegistered) return
|
||||
isRegistered = true
|
||||
|
||||
val filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
|
||||
context.registerReceiver(this, filter)
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters this receiver if it's not already.
|
||||
*/
|
||||
fun unregister() {
|
||||
if (!isRegistered) return
|
||||
isRegistered = false
|
||||
|
||||
context.unregisterReceiver(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a download event is received. It looks for the download in the current active
|
||||
* downloads and notifies its installation step.
|
||||
*/
|
||||
override fun onReceive(context: Context, intent: Intent?) {
|
||||
val id = intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0) ?: return
|
||||
|
||||
// Avoid events for downloads we didn't request
|
||||
if (id !in activeDownloads.values) return
|
||||
|
||||
val uri = downloadManager.getUriForDownloadedFile(id)
|
||||
|
||||
// Set next installation step
|
||||
if (uri == null) {
|
||||
logcat(LogPriority.ERROR) { "Couldn't locate downloaded APK" }
|
||||
downloadsRelay.call(id to InstallStep.Error)
|
||||
return
|
||||
}
|
||||
|
||||
val query = DownloadManager.Query().setFilterById(id)
|
||||
downloadManager.query(query).use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val localUri = cursor.getString(
|
||||
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_URI),
|
||||
).removePrefix(FILE_SCHEME)
|
||||
|
||||
installApk(id, File(localUri).getUriCompat(context))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val APK_MIME = "application/vnd.android.package-archive"
|
||||
const val EXTRA_DOWNLOAD_ID = "AnimeExtensionInstaller.extra.DOWNLOAD_ID"
|
||||
const val FILE_SCHEME = "file://"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,232 @@
|
|||
package ani.dantotsu.aniyomi.anime.util
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.content.pm.PackageInfoCompat
|
||||
import dalvik.system.PathClassLoader
|
||||
import ani.dantotsu.aniyomi.domain.source.service.SourcePreferences
|
||||
import ani.dantotsu.aniyomi.animesource.AnimeCatalogueSource
|
||||
import ani.dantotsu.aniyomi.animesource.AnimeSource
|
||||
import ani.dantotsu.aniyomi.animesource.AnimeSourceFactory
|
||||
import ani.dantotsu.aniyomi.anime.model.AnimeExtension
|
||||
import ani.dantotsu.aniyomi.anime.model.AnimeLoadResult
|
||||
import ani.dantotsu.aniyomi.util.lang.Hash
|
||||
import ani.dantotsu.aniyomi.util.system.getApplicationIcon
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import logcat.LogPriority
|
||||
import ani.dantotsu.aniyomi.util.logcat
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
/**
|
||||
* Class that handles the loading of the extensions installed in the system.
|
||||
*/
|
||||
@SuppressLint("PackageManagerGetSignatures")
|
||||
internal object AnimeExtensionLoader {
|
||||
|
||||
private val preferences: SourcePreferences by injectLazy()
|
||||
private val loadNsfwSource by lazy {
|
||||
preferences.showNsfwSource().get()
|
||||
}
|
||||
|
||||
private const val EXTENSION_FEATURE = "tachiyomi.animeextension"
|
||||
private const val METADATA_SOURCE_CLASS = "tachiyomi.animeextension.class"
|
||||
private const val METADATA_SOURCE_FACTORY = "tachiyomi.animeextension.factory"
|
||||
private const val METADATA_NSFW = "tachiyomi.animeextension.nsfw"
|
||||
private const val METADATA_HAS_README = "tachiyomi.animeextension.hasReadme"
|
||||
private const val METADATA_HAS_CHANGELOG = "tachiyomi.animeextension.hasChangelog"
|
||||
const val LIB_VERSION_MIN = 12
|
||||
const val LIB_VERSION_MAX = 15
|
||||
|
||||
private const val PACKAGE_FLAGS = PackageManager.GET_CONFIGURATIONS or PackageManager.GET_SIGNATURES
|
||||
|
||||
// jmir1's key
|
||||
private const val officialSignature = "50ab1d1e3a20d204d0ad6d334c7691c632e41b98dfa132bf385695fdfa63839c"
|
||||
|
||||
/**
|
||||
* List of the trusted signatures.
|
||||
*/
|
||||
var trustedSignatures = mutableSetOf<String>() + preferences.trustedSignatures().get() + officialSignature
|
||||
|
||||
/**
|
||||
* Return a list of all the installed extensions initialized concurrently.
|
||||
*
|
||||
* @param context The application context.
|
||||
*/
|
||||
fun loadExtensions(context: Context): List<AnimeLoadResult> {
|
||||
val pkgManager = context.packageManager
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
val installedPkgs = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
pkgManager.getInstalledPackages(PackageManager.PackageInfoFlags.of(PACKAGE_FLAGS.toLong()))
|
||||
} else {
|
||||
pkgManager.getInstalledPackages(PACKAGE_FLAGS)
|
||||
}
|
||||
|
||||
val extPkgs = installedPkgs.filter { isPackageAnExtension(it) }
|
||||
|
||||
if (extPkgs.isEmpty()) return emptyList()
|
||||
|
||||
// Load each extension concurrently and wait for completion
|
||||
return runBlocking {
|
||||
val deferred = extPkgs.map {
|
||||
async { loadExtension(context, it.packageName, it) }
|
||||
}
|
||||
deferred.map { it.await() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to load an extension from the given package name. It checks if the extension
|
||||
* contains the required feature flag before trying to load it.
|
||||
*/
|
||||
fun loadExtensionFromPkgName(context: Context, pkgName: String): AnimeLoadResult {
|
||||
val pkgInfo = try {
|
||||
context.packageManager.getPackageInfo(pkgName, PACKAGE_FLAGS)
|
||||
} catch (error: PackageManager.NameNotFoundException) {
|
||||
// Unlikely, but the package may have been uninstalled at this point
|
||||
logcat(LogPriority.ERROR, error)
|
||||
return AnimeLoadResult.Error
|
||||
}
|
||||
if (!isPackageAnExtension(pkgInfo)) {
|
||||
logcat(LogPriority.WARN) { "Tried to load a package that wasn't a extension ($pkgName)" }
|
||||
return AnimeLoadResult.Error
|
||||
}
|
||||
return loadExtension(context, pkgName, pkgInfo)
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an extension given its package name.
|
||||
*
|
||||
* @param context The application context.
|
||||
* @param pkgName The package name of the extension to load.
|
||||
* @param pkgInfo The package info of the extension.
|
||||
*/
|
||||
private fun loadExtension(context: Context, pkgName: String, pkgInfo: PackageInfo): AnimeLoadResult {
|
||||
val pkgManager = context.packageManager
|
||||
|
||||
val appInfo = try {
|
||||
pkgManager.getApplicationInfo(pkgName, PackageManager.GET_META_DATA)
|
||||
} catch (error: PackageManager.NameNotFoundException) {
|
||||
// Unlikely, but the package may have been uninstalled at this point
|
||||
logcat(LogPriority.ERROR, error)
|
||||
return AnimeLoadResult.Error
|
||||
}
|
||||
|
||||
val extName = pkgManager.getApplicationLabel(appInfo).toString().substringAfter("Aniyomi: ")
|
||||
val versionName = pkgInfo.versionName
|
||||
val versionCode = PackageInfoCompat.getLongVersionCode(pkgInfo)
|
||||
|
||||
if (versionName.isNullOrEmpty()) {
|
||||
logcat(LogPriority.WARN) { "Missing versionName for extension $extName" }
|
||||
return AnimeLoadResult.Error
|
||||
}
|
||||
|
||||
// Validate lib version
|
||||
val libVersion = versionName.substringBeforeLast('.').toDoubleOrNull()
|
||||
if (libVersion == null || libVersion < LIB_VERSION_MIN || libVersion > LIB_VERSION_MAX) {
|
||||
logcat(LogPriority.WARN) {
|
||||
"Lib version is $libVersion, while only versions " +
|
||||
"$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed"
|
||||
}
|
||||
return AnimeLoadResult.Error
|
||||
}
|
||||
|
||||
val signatureHash = getSignatureHash(pkgInfo)
|
||||
|
||||
if (signatureHash == null) {
|
||||
logcat(LogPriority.WARN) { "Package $pkgName isn't signed" }
|
||||
return AnimeLoadResult.Error
|
||||
} else if (signatureHash !in trustedSignatures) {
|
||||
val extension = AnimeExtension.Untrusted(extName, pkgName, versionName, versionCode, libVersion, signatureHash)
|
||||
logcat(LogPriority.WARN, message = { "Extension $pkgName isn't trusted" })
|
||||
return AnimeLoadResult.Untrusted(extension)
|
||||
}
|
||||
|
||||
val isNsfw = appInfo.metaData.getInt(METADATA_NSFW) == 1
|
||||
if (!loadNsfwSource && isNsfw) {
|
||||
logcat(LogPriority.WARN) { "NSFW extension $pkgName not allowed" }
|
||||
return AnimeLoadResult.Error
|
||||
}
|
||||
|
||||
val hasReadme = appInfo.metaData.getInt(METADATA_HAS_README, 0) == 1
|
||||
val hasChangelog = appInfo.metaData.getInt(METADATA_HAS_CHANGELOG, 0) == 1
|
||||
|
||||
val classLoader = PathClassLoader(appInfo.sourceDir, null, context.classLoader)
|
||||
|
||||
val sources = appInfo.metaData.getString(METADATA_SOURCE_CLASS)!!
|
||||
.split(";")
|
||||
.map {
|
||||
val sourceClass = it.trim()
|
||||
if (sourceClass.startsWith(".")) {
|
||||
pkgInfo.packageName + sourceClass
|
||||
} else {
|
||||
sourceClass
|
||||
}
|
||||
}
|
||||
.flatMap {
|
||||
try {
|
||||
when (val obj = Class.forName(it, false, classLoader).newInstance()) {
|
||||
is AnimeSource -> listOf(obj)
|
||||
is AnimeSourceFactory -> obj.createSources()
|
||||
else -> throw Exception("Unknown source class type! ${obj.javaClass}")
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
logcat(LogPriority.ERROR, e) { "Extension load error: $extName ($it)" }
|
||||
return AnimeLoadResult.Error
|
||||
}
|
||||
}
|
||||
|
||||
val langs = sources.filterIsInstance<AnimeCatalogueSource>()
|
||||
.map { it.lang }
|
||||
.toSet()
|
||||
val lang = when (langs.size) {
|
||||
0 -> ""
|
||||
1 -> langs.first()
|
||||
else -> "all"
|
||||
}
|
||||
|
||||
val extension = AnimeExtension.Installed(
|
||||
name = extName,
|
||||
pkgName = pkgName,
|
||||
versionName = versionName,
|
||||
versionCode = versionCode,
|
||||
libVersion = libVersion,
|
||||
lang = lang,
|
||||
isNsfw = isNsfw,
|
||||
hasReadme = hasReadme,
|
||||
hasChangelog = hasChangelog,
|
||||
sources = sources,
|
||||
pkgFactory = appInfo.metaData.getString(METADATA_SOURCE_FACTORY),
|
||||
isUnofficial = signatureHash != officialSignature,
|
||||
icon = context.getApplicationIcon(pkgName),
|
||||
)
|
||||
return AnimeLoadResult.Success(extension)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given package is an extension.
|
||||
*
|
||||
* @param pkgInfo The package info of the application.
|
||||
*/
|
||||
private fun isPackageAnExtension(pkgInfo: PackageInfo): Boolean {
|
||||
return pkgInfo.reqFeatures.orEmpty().any { it.name == EXTENSION_FEATURE }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the signature hash of the package or null if it's not signed.
|
||||
*
|
||||
* @param pkgInfo The package info of the application.
|
||||
*/
|
||||
private fun getSignatureHash(pkgInfo: PackageInfo): String? {
|
||||
val signatures = pkgInfo.signatures
|
||||
return if (signatures != null && signatures.isNotEmpty()) {
|
||||
Hash.sha256(signatures.first().toByteArray())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
package ani.dantotsu.aniyomi.animesource
|
||||
|
||||
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
|
||||
import eu.kanade.tachiyomi.animesource.model.AnimesPage
|
||||
import rx.Observable
|
||||
|
||||
interface AnimeCatalogueSource : AnimeSource {
|
||||
|
||||
/**
|
||||
* An ISO 639-1 compliant language code (two letters in lower case).
|
||||
*/
|
||||
override val lang: String
|
||||
|
||||
/**
|
||||
* Whether the source has support for latest updates.
|
||||
*/
|
||||
val supportsLatest: Boolean
|
||||
|
||||
/**
|
||||
* Returns an observable containing a page with a list of anime.
|
||||
*
|
||||
* @param page the page number to retrieve.
|
||||
*/
|
||||
fun fetchPopularAnime(page: Int): Observable<AnimesPage>
|
||||
|
||||
/**
|
||||
* Returns an observable containing a page with a list of anime.
|
||||
*
|
||||
* @param page the page number to retrieve.
|
||||
* @param query the search query.
|
||||
* @param filters the list of filters to apply.
|
||||
*/
|
||||
fun fetchSearchAnime(page: Int, query: String, filters: AnimeFilterList): Observable<AnimesPage>
|
||||
|
||||
/**
|
||||
* Returns an observable containing a page with a list of latest anime updates.
|
||||
*
|
||||
* @param page the page number to retrieve.
|
||||
*/
|
||||
fun fetchLatestUpdates(page: Int): Observable<AnimesPage>
|
||||
|
||||
/**
|
||||
* Returns the list of filters for the source.
|
||||
*/
|
||||
fun getFilterList(): AnimeFilterList
|
||||
}
|
|
@ -0,0 +1,86 @@
|
|||
package ani.dantotsu.aniyomi.animesource
|
||||
|
||||
import eu.kanade.tachiyomi.animesource.model.SAnime
|
||||
import eu.kanade.tachiyomi.animesource.model.SEpisode
|
||||
import eu.kanade.tachiyomi.animesource.model.Video
|
||||
//import ani.dantotsu.aniyomi.util.awaitSingle
|
||||
import ani.dantotsu.aniyomi.util.lang.awaitSingle
|
||||
import rx.Observable
|
||||
|
||||
/**
|
||||
* A basic interface for creating a source. It could be an online source, a local source, etc.
|
||||
*/
|
||||
interface AnimeSource {
|
||||
|
||||
/**
|
||||
* ID for the source. Must be unique.
|
||||
*/
|
||||
val id: Long
|
||||
|
||||
/**
|
||||
* Name of the source.
|
||||
*/
|
||||
val name: String
|
||||
|
||||
val lang: String
|
||||
get() = ""
|
||||
|
||||
/**
|
||||
* Returns an observable with the updated details for a anime.
|
||||
*
|
||||
* @param anime the anime to update.
|
||||
*/
|
||||
@Deprecated(
|
||||
"Use the 1.x API instead",
|
||||
ReplaceWith("getAnimeDetails"),
|
||||
)
|
||||
fun fetchAnimeDetails(anime: SAnime): Observable<SAnime> = throw IllegalStateException("Not used")
|
||||
|
||||
/**
|
||||
* Returns an observable with all the available episodes for a anime.
|
||||
*
|
||||
* @param anime the anime to update.
|
||||
*/
|
||||
@Deprecated(
|
||||
"Use the 1.x API instead",
|
||||
ReplaceWith("getEpisodeList"),
|
||||
)
|
||||
fun fetchEpisodeList(anime: SAnime): Observable<List<SEpisode>> = throw IllegalStateException("Not used")
|
||||
|
||||
/**
|
||||
* Returns an observable with the list of videos a episode has. Videos should be returned
|
||||
* in the expected order; the index is ignored.
|
||||
*
|
||||
* @param episode the episode.
|
||||
*/
|
||||
@Deprecated(
|
||||
"Use the 1.x API instead",
|
||||
ReplaceWith("getVideoList"),
|
||||
)
|
||||
fun fetchVideoList(episode: SEpisode): Observable<List<Video>> = Observable.empty()
|
||||
|
||||
/**
|
||||
* [1.x API] Get the updated details for a anime.
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
suspend fun getAnimeDetails(anime: SAnime): SAnime {
|
||||
return fetchAnimeDetails(anime).awaitSingle()
|
||||
}
|
||||
|
||||
/**
|
||||
* [1.x API] Get all the available episodes for a anime.
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
suspend fun getEpisodeList(anime: SAnime): List<SEpisode> {
|
||||
return fetchEpisodeList(anime).awaitSingle()
|
||||
}
|
||||
|
||||
/**
|
||||
* [1.x API] Get the list of videos a episode has. Videos should be returned
|
||||
* in the expected order; the index is ignored.
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
suspend fun getVideoList(episode: SEpisode): List<Video> {
|
||||
return fetchVideoList(episode).awaitSingle()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
package ani.dantotsu.aniyomi.animesource
|
||||
|
||||
/**
|
||||
* A factory for creating sources at runtime.
|
||||
*/
|
||||
interface AnimeSourceFactory {
|
||||
/**
|
||||
* Create a new copy of the sources
|
||||
* @return The created sources
|
||||
*/
|
||||
fun createSources(): List<AnimeSource>
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
package eu.kanade.tachiyomi.animesource.model
|
||||
|
||||
sealed class AnimeFilter<T>(val name: String, var state: T) {
|
||||
open class Header(name: String) : AnimeFilter<Any>(name, 0)
|
||||
open class Separator(name: String = "") : AnimeFilter<Any>(name, 0)
|
||||
abstract class Select<V>(name: String, val values: Array<V>, state: Int = 0) : AnimeFilter<Int>(name, state)
|
||||
abstract class Text(name: String, state: String = "") : AnimeFilter<String>(name, state)
|
||||
abstract class CheckBox(name: String, state: Boolean = false) : AnimeFilter<Boolean>(name, state)
|
||||
abstract class TriState(name: String, state: Int = STATE_IGNORE) : AnimeFilter<Int>(name, state) {
|
||||
fun isIgnored() = state == STATE_IGNORE
|
||||
fun isIncluded() = state == STATE_INCLUDE
|
||||
fun isExcluded() = state == STATE_EXCLUDE
|
||||
|
||||
companion object {
|
||||
const val STATE_IGNORE = 0
|
||||
const val STATE_INCLUDE = 1
|
||||
const val STATE_EXCLUDE = 2
|
||||
}
|
||||
}
|
||||
|
||||
abstract class Group<V>(name: String, state: List<V>) : AnimeFilter<List<V>>(name, state)
|
||||
|
||||
abstract class Sort(name: String, val values: Array<String>, state: Selection? = null) :
|
||||
AnimeFilter<Sort.Selection?>(name, state) {
|
||||
data class Selection(val index: Int, val ascending: Boolean)
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is AnimeFilter<*>) return false
|
||||
|
||||
return name == other.name && state == other.state
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = name.hashCode()
|
||||
result = 31 * result + (state?.hashCode() ?: 0)
|
||||
return result
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
package eu.kanade.tachiyomi.animesource.model
|
||||
|
||||
|
||||
data class AnimeFilterList(val list: List<AnimeFilter<*>>) : List<AnimeFilter<*>> by list {
|
||||
|
||||
constructor(vararg fs: AnimeFilter<*>) : this(if (fs.isNotEmpty()) fs.asList() else emptyList())
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return list.hashCode()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
package eu.kanade.tachiyomi.animesource.model
|
||||
|
||||
|
||||
data class AnimesPage(val animes: List<SAnime>, val hasNextPage: Boolean)
|
|
@ -0,0 +1,89 @@
|
|||
package eu.kanade.tachiyomi.animesource.model
|
||||
|
||||
import ani.dantotsu.aniyomi.source.model.UpdateStrategy
|
||||
import java.io.Serializable
|
||||
|
||||
interface SAnime : Serializable {
|
||||
|
||||
var url: String
|
||||
|
||||
var title: String
|
||||
|
||||
var artist: String?
|
||||
|
||||
var author: String?
|
||||
|
||||
var description: String?
|
||||
|
||||
var genre: String?
|
||||
|
||||
var status: Int
|
||||
|
||||
var thumbnail_url: String?
|
||||
|
||||
var update_strategy: UpdateStrategy
|
||||
|
||||
var initialized: Boolean
|
||||
|
||||
fun getGenres(): List<String>? {
|
||||
if (genre.isNullOrBlank()) return null
|
||||
return genre?.split(", ")?.map { it.trim() }?.filterNot { it.isBlank() }?.distinct()
|
||||
}
|
||||
|
||||
fun copyFrom(other: SAnime) {
|
||||
if (other.author != null) {
|
||||
author = other.author
|
||||
}
|
||||
|
||||
if (other.artist != null) {
|
||||
artist = other.artist
|
||||
}
|
||||
|
||||
if (other.description != null) {
|
||||
description = other.description
|
||||
}
|
||||
|
||||
if (other.genre != null) {
|
||||
genre = other.genre
|
||||
}
|
||||
|
||||
if (other.thumbnail_url != null) {
|
||||
thumbnail_url = other.thumbnail_url
|
||||
}
|
||||
|
||||
status = other.status
|
||||
|
||||
update_strategy = other.update_strategy
|
||||
|
||||
if (!initialized) {
|
||||
initialized = other.initialized
|
||||
}
|
||||
}
|
||||
|
||||
fun copy() = create().also {
|
||||
it.url = url
|
||||
it.title = title
|
||||
it.artist = artist
|
||||
it.author = author
|
||||
it.description = description
|
||||
it.genre = genre
|
||||
it.status = status
|
||||
it.thumbnail_url = thumbnail_url
|
||||
it.update_strategy = update_strategy
|
||||
it.initialized = initialized
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val UNKNOWN = 0
|
||||
const val ONGOING = 1
|
||||
const val COMPLETED = 2
|
||||
const val LICENSED = 3
|
||||
const val PUBLISHING_FINISHED = 4
|
||||
const val CANCELLED = 5
|
||||
const val ON_HIATUS = 6
|
||||
|
||||
fun create(): SAnime {
|
||||
return SAnimeImpl()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
package eu.kanade.tachiyomi.animesource.model
|
||||
|
||||
import ani.dantotsu.aniyomi.source.model.UpdateStrategy
|
||||
|
||||
class SAnimeImpl : SAnime {
|
||||
|
||||
override lateinit var url: String
|
||||
|
||||
override lateinit var title: String
|
||||
|
||||
override var artist: String? = null
|
||||
|
||||
override var author: String? = null
|
||||
|
||||
override var description: String? = null
|
||||
|
||||
override var genre: String? = null
|
||||
|
||||
override var status: Int = 0
|
||||
|
||||
override var thumbnail_url: String? = null
|
||||
|
||||
override var initialized: Boolean = false
|
||||
|
||||
override var update_strategy: UpdateStrategy = UpdateStrategy.ALWAYS_UPDATE
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
package eu.kanade.tachiyomi.animesource.model
|
||||
|
||||
import java.io.Serializable
|
||||
|
||||
interface SEpisode : Serializable {
|
||||
|
||||
var url: String
|
||||
|
||||
var name: String
|
||||
|
||||
var date_upload: Long
|
||||
|
||||
var episode_number: Float
|
||||
|
||||
var scanlator: String?
|
||||
|
||||
fun copyFrom(other: SEpisode) {
|
||||
name = other.name
|
||||
url = other.url
|
||||
date_upload = other.date_upload
|
||||
episode_number = other.episode_number
|
||||
scanlator = other.scanlator
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun create(): SEpisode {
|
||||
return SEpisodeImpl()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
package eu.kanade.tachiyomi.animesource.model
|
||||
|
||||
|
||||
class SEpisodeImpl : SEpisode {
|
||||
|
||||
override lateinit var url: String
|
||||
|
||||
override lateinit var name: String
|
||||
|
||||
override var date_upload: Long = 0
|
||||
|
||||
override var episode_number: Float = -1f
|
||||
|
||||
override var scanlator: String? = null
|
||||
}
|
|
@ -0,0 +1,92 @@
|
|||
package eu.kanade.tachiyomi.animesource.model
|
||||
|
||||
import android.net.Uri
|
||||
import ani.dantotsu.aniyomi.util.network.ProgressListener
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import okhttp3.Headers
|
||||
import rx.subjects.Subject
|
||||
|
||||
data class Track(val url: String, val lang: String)
|
||||
|
||||
open class Video(
|
||||
val url: String = "",
|
||||
val quality: String = "",
|
||||
var videoUrl: String? = null,
|
||||
val headers: Headers? = null,
|
||||
// "url", "language-label-2", "url2", "language-label-2"
|
||||
val subtitleTracks: List<Track> = emptyList(),
|
||||
val audioTracks: List<Track> = emptyList(),
|
||||
) : ProgressListener {
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
constructor(
|
||||
url: String,
|
||||
quality: String,
|
||||
videoUrl: String?,
|
||||
uri: Uri? = null,
|
||||
headers: Headers? = null,
|
||||
) : this(url, quality, videoUrl, headers)
|
||||
|
||||
@Transient
|
||||
@Volatile
|
||||
var status: State = State.QUEUE
|
||||
set(value) {
|
||||
field = value
|
||||
}
|
||||
|
||||
@Transient
|
||||
private val _progressFlow = MutableStateFlow(0)
|
||||
|
||||
@Transient
|
||||
val progressFlow = _progressFlow.asStateFlow()
|
||||
var progress: Int
|
||||
get() = _progressFlow.value
|
||||
set(value) {
|
||||
_progressFlow.value = value
|
||||
}
|
||||
|
||||
@Transient
|
||||
@Volatile
|
||||
var totalBytesDownloaded: Long = 0L
|
||||
|
||||
@Transient
|
||||
@Volatile
|
||||
var totalContentLength: Long = 0L
|
||||
|
||||
@Transient
|
||||
@Volatile
|
||||
var bytesDownloaded: Long = 0L
|
||||
set(value) {
|
||||
totalBytesDownloaded += if (value < field) {
|
||||
value
|
||||
} else {
|
||||
value - field
|
||||
}
|
||||
field = value
|
||||
}
|
||||
|
||||
@Transient
|
||||
var progressSubject: Subject<State, State>? = null
|
||||
|
||||
override fun update(bytesRead: Long, contentLength: Long, done: Boolean) {
|
||||
bytesDownloaded = bytesRead
|
||||
if (contentLength > totalContentLength) {
|
||||
totalContentLength = contentLength
|
||||
}
|
||||
val newProgress = if (totalContentLength > 0) {
|
||||
(100 * totalBytesDownloaded / totalContentLength).toInt()
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
if (progress != newProgress) progress = newProgress
|
||||
}
|
||||
|
||||
enum class State {
|
||||
QUEUE,
|
||||
LOAD_VIDEO,
|
||||
DOWNLOAD_IMAGE,
|
||||
READY,
|
||||
ERROR,
|
||||
}
|
||||
}
|
24
app/src/main/java/ani/dantotsu/aniyomi/core/Constants.kt
Normal file
24
app/src/main/java/ani/dantotsu/aniyomi/core/Constants.kt
Normal file
|
@ -0,0 +1,24 @@
|
|||
package ani.dantotsu.aniyomi.core
|
||||
|
||||
object Constants {
|
||||
const val URL_HELP = "https://aniyomi.org/help/"
|
||||
|
||||
const val MANGA_EXTRA = "manga"
|
||||
|
||||
const val ANIME_EXTRA = "anime"
|
||||
|
||||
const val MAIN_ACTIVITY = "eu.kanade.tachiyomi.ui.main.MainActivity"
|
||||
|
||||
// Shortcut actions
|
||||
const val SHORTCUT_ANIMELIB = "eu.kanade.tachiyomi.SHOW_ANIMELIB"
|
||||
const val SHORTCUT_LIBRARY = "eu.kanade.tachiyomi.SHOW_LIBRARY"
|
||||
const val SHORTCUT_ANIME = "eu.kanade.tachiyomi.SHOW_ANIME"
|
||||
const val SHORTCUT_MANGA = "eu.kanade.tachiyomi.SHOW_MANGA"
|
||||
const val SHORTCUT_UPDATES = "eu.kanade.tachiyomi.SHOW_RECENTLY_UPDATED"
|
||||
const val SHORTCUT_HISTORY = "eu.kanade.tachiyomi.SHOW_RECENTLY_READ"
|
||||
const val SHORTCUT_SOURCES = "eu.kanade.tachiyomi.SHOW_CATALOGUES"
|
||||
const val SHORTCUT_ANIMEEXTENSIONS = "eu.kanade.tachiyomi.ANIMEEXTENSIONS"
|
||||
const val SHORTCUT_EXTENSIONS = "eu.kanade.tachiyomi.EXTENSIONS"
|
||||
const val SHORTCUT_ANIME_DOWNLOADS = "eu.kanade.tachiyomi.SHOW_ANIME_DOWNLOADS"
|
||||
const val SHORTCUT_DOWNLOADS = "eu.kanade.tachiyomi.SHOW_DOWNLOADS"
|
||||
}
|
|
@ -0,0 +1,199 @@
|
|||
package ani.dantotsu.aniyomi.core.preference
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.content.SharedPreferences.Editor
|
||||
import androidx.core.content.edit
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.conflate
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
sealed class AndroidPreference<T>(
|
||||
private val preferences: SharedPreferences,
|
||||
private val keyFlow: Flow<String?>,
|
||||
private val key: String,
|
||||
private val defaultValue: T,
|
||||
) : Preference<T> {
|
||||
|
||||
abstract fun read(preferences: SharedPreferences, key: String, defaultValue: T): T
|
||||
|
||||
abstract fun write(key: String, value: T): Editor.() -> Unit
|
||||
|
||||
override fun key(): String {
|
||||
return key
|
||||
}
|
||||
|
||||
override fun get(): T {
|
||||
return read(preferences, key, defaultValue)
|
||||
}
|
||||
|
||||
override fun set(value: T) {
|
||||
preferences.edit(action = write(key, value))
|
||||
}
|
||||
|
||||
override fun isSet(): Boolean {
|
||||
return preferences.contains(key)
|
||||
}
|
||||
|
||||
override fun delete() {
|
||||
preferences.edit {
|
||||
remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
override fun defaultValue(): T {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
override fun changes(): Flow<T> {
|
||||
return keyFlow
|
||||
.filter { it == key || it == null }
|
||||
.onStart { emit("ignition") }
|
||||
.map { get() }
|
||||
.conflate()
|
||||
}
|
||||
|
||||
override fun stateIn(scope: CoroutineScope): StateFlow<T> {
|
||||
return changes().stateIn(scope, SharingStarted.Eagerly, get())
|
||||
}
|
||||
|
||||
class StringPrimitive(
|
||||
preferences: SharedPreferences,
|
||||
keyFlow: Flow<String?>,
|
||||
key: String,
|
||||
defaultValue: String,
|
||||
) : AndroidPreference<String>(preferences, keyFlow, key, defaultValue) {
|
||||
override fun read(preferences: SharedPreferences, key: String, defaultValue: String): String {
|
||||
return try {
|
||||
preferences.getString(key, defaultValue) ?: defaultValue
|
||||
} catch (e: ClassCastException) {
|
||||
defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
override fun write(key: String, value: String): Editor.() -> Unit = {
|
||||
putString(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
class LongPrimitive(
|
||||
preferences: SharedPreferences,
|
||||
keyFlow: Flow<String?>,
|
||||
key: String,
|
||||
defaultValue: Long,
|
||||
) : AndroidPreference<Long>(preferences, keyFlow, key, defaultValue) {
|
||||
override fun read(preferences: SharedPreferences, key: String, defaultValue: Long): Long {
|
||||
return try {
|
||||
preferences.getLong(key, defaultValue)
|
||||
} catch (e: ClassCastException) {
|
||||
defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
override fun write(key: String, value: Long): Editor.() -> Unit = {
|
||||
putLong(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
class IntPrimitive(
|
||||
preferences: SharedPreferences,
|
||||
keyFlow: Flow<String?>,
|
||||
key: String,
|
||||
defaultValue: Int,
|
||||
) : AndroidPreference<Int>(preferences, keyFlow, key, defaultValue) {
|
||||
override fun read(preferences: SharedPreferences, key: String, defaultValue: Int): Int {
|
||||
return try {
|
||||
preferences.getInt(key, defaultValue)
|
||||
} catch (e: ClassCastException) {
|
||||
defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
override fun write(key: String, value: Int): Editor.() -> Unit = {
|
||||
putInt(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
class FloatPrimitive(
|
||||
preferences: SharedPreferences,
|
||||
keyFlow: Flow<String?>,
|
||||
key: String,
|
||||
defaultValue: Float,
|
||||
) : AndroidPreference<Float>(preferences, keyFlow, key, defaultValue) {
|
||||
override fun read(preferences: SharedPreferences, key: String, defaultValue: Float): Float {
|
||||
return try {
|
||||
preferences.getFloat(key, defaultValue)
|
||||
} catch (e: ClassCastException) {
|
||||
defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
override fun write(key: String, value: Float): Editor.() -> Unit = {
|
||||
putFloat(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
class BooleanPrimitive(
|
||||
preferences: SharedPreferences,
|
||||
keyFlow: Flow<String?>,
|
||||
key: String,
|
||||
defaultValue: Boolean,
|
||||
) : AndroidPreference<Boolean>(preferences, keyFlow, key, defaultValue) {
|
||||
override fun read(preferences: SharedPreferences, key: String, defaultValue: Boolean): Boolean {
|
||||
return try {
|
||||
preferences.getBoolean(key, defaultValue)
|
||||
} catch (e: ClassCastException) {
|
||||
defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
override fun write(key: String, value: Boolean): Editor.() -> Unit = {
|
||||
putBoolean(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
class StringSetPrimitive(
|
||||
preferences: SharedPreferences,
|
||||
keyFlow: Flow<String?>,
|
||||
key: String,
|
||||
defaultValue: Set<String>,
|
||||
) : AndroidPreference<Set<String>>(preferences, keyFlow, key, defaultValue) {
|
||||
override fun read(preferences: SharedPreferences, key: String, defaultValue: Set<String>): Set<String> {
|
||||
return try {
|
||||
preferences.getStringSet(key, defaultValue) ?: defaultValue
|
||||
} catch (e: ClassCastException) {
|
||||
defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
override fun write(key: String, value: Set<String>): Editor.() -> Unit = {
|
||||
putStringSet(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
class Object<T>(
|
||||
preferences: SharedPreferences,
|
||||
keyFlow: Flow<String?>,
|
||||
key: String,
|
||||
defaultValue: T,
|
||||
val serializer: (T) -> String,
|
||||
val deserializer: (String) -> T,
|
||||
) : AndroidPreference<T>(preferences, keyFlow, key, defaultValue) {
|
||||
override fun read(preferences: SharedPreferences, key: String, defaultValue: T): T {
|
||||
return try {
|
||||
preferences.getString(key, null)?.let(deserializer) ?: defaultValue
|
||||
} catch (e: Exception) {
|
||||
defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
override fun write(key: String, value: T): Editor.() -> Unit = {
|
||||
putString(key, serializer(value))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,74 @@
|
|||
package ani.dantotsu.aniyomi.core.preference
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.preference.PreferenceManager
|
||||
import ani.dantotsu.aniyomi.core.preference.AndroidPreference.BooleanPrimitive
|
||||
import ani.dantotsu.aniyomi.core.preference.AndroidPreference.FloatPrimitive
|
||||
import ani.dantotsu.aniyomi.core.preference.AndroidPreference.IntPrimitive
|
||||
import ani.dantotsu.aniyomi.core.preference.AndroidPreference.LongPrimitive
|
||||
import ani.dantotsu.aniyomi.core.preference.AndroidPreference.StringPrimitive
|
||||
import ani.dantotsu.aniyomi.core.preference.AndroidPreference.StringSetPrimitive
|
||||
import ani.dantotsu.aniyomi.core.preference.AndroidPreference.Object
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
|
||||
class AndroidPreferenceStore(
|
||||
context: Context,
|
||||
) : PreferenceStore {
|
||||
|
||||
private val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
|
||||
private val keyFlow = sharedPreferences.keyFlow
|
||||
|
||||
override fun getString(key: String, defaultValue: String): Preference<String> {
|
||||
return StringPrimitive(sharedPreferences, keyFlow, key, defaultValue)
|
||||
}
|
||||
|
||||
override fun getLong(key: String, defaultValue: Long): Preference<Long> {
|
||||
return LongPrimitive(sharedPreferences, keyFlow, key, defaultValue)
|
||||
}
|
||||
|
||||
override fun getInt(key: String, defaultValue: Int): Preference<Int> {
|
||||
return IntPrimitive(sharedPreferences, keyFlow, key, defaultValue)
|
||||
}
|
||||
|
||||
override fun getFloat(key: String, defaultValue: Float): Preference<Float> {
|
||||
return FloatPrimitive(sharedPreferences, keyFlow, key, defaultValue)
|
||||
}
|
||||
|
||||
override fun getBoolean(key: String, defaultValue: Boolean): Preference<Boolean> {
|
||||
return BooleanPrimitive(sharedPreferences, keyFlow, key, defaultValue)
|
||||
}
|
||||
|
||||
override fun getStringSet(key: String, defaultValue: Set<String>): Preference<Set<String>> {
|
||||
return StringSetPrimitive(sharedPreferences, keyFlow, key, defaultValue)
|
||||
}
|
||||
|
||||
override fun <T> getObject(
|
||||
key: String,
|
||||
defaultValue: T,
|
||||
serializer: (T) -> String,
|
||||
deserializer: (String) -> T,
|
||||
): Preference<T> {
|
||||
return Object(
|
||||
preferences = sharedPreferences,
|
||||
keyFlow = keyFlow,
|
||||
key = key,
|
||||
defaultValue = defaultValue,
|
||||
serializer = serializer,
|
||||
deserializer = deserializer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private val SharedPreferences.keyFlow
|
||||
get() = callbackFlow {
|
||||
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key: String? -> trySend(key) }
|
||||
registerOnSharedPreferenceChangeListener(listener)
|
||||
awaitClose {
|
||||
unregisterOnSharedPreferenceChangeListener(listener)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
package ani.dantotsu.aniyomi.core.preference
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
interface Preference<T> {
|
||||
|
||||
fun key(): String
|
||||
|
||||
fun get(): T
|
||||
|
||||
fun set(value: T)
|
||||
|
||||
fun isSet(): Boolean
|
||||
|
||||
fun delete()
|
||||
|
||||
fun defaultValue(): T
|
||||
|
||||
fun changes(): Flow<T>
|
||||
|
||||
fun stateIn(scope: CoroutineScope): StateFlow<T>
|
||||
}
|
||||
|
||||
inline fun <reified T, R : T> Preference<T>.getAndSet(crossinline block: (T) -> R) = set(block(get()))
|
|
@ -0,0 +1,41 @@
|
|||
package ani.dantotsu.aniyomi.core.preference
|
||||
|
||||
interface PreferenceStore {
|
||||
|
||||
fun getString(key: String, defaultValue: String = ""): Preference<String>
|
||||
|
||||
fun getLong(key: String, defaultValue: Long = 0): Preference<Long>
|
||||
|
||||
fun getInt(key: String, defaultValue: Int = 0): Preference<Int>
|
||||
|
||||
fun getFloat(key: String, defaultValue: Float = 0f): Preference<Float>
|
||||
|
||||
fun getBoolean(key: String, defaultValue: Boolean = false): Preference<Boolean>
|
||||
|
||||
fun getStringSet(key: String, defaultValue: Set<String> = emptySet()): Preference<Set<String>>
|
||||
|
||||
fun <T> getObject(
|
||||
key: String,
|
||||
defaultValue: T,
|
||||
serializer: (T) -> String,
|
||||
deserializer: (String) -> T,
|
||||
): Preference<T>
|
||||
}
|
||||
|
||||
inline fun <reified T : Enum<T>> PreferenceStore.getEnum(
|
||||
key: String,
|
||||
defaultValue: T,
|
||||
): Preference<T> {
|
||||
return getObject(
|
||||
key = key,
|
||||
defaultValue = defaultValue,
|
||||
serializer = { it.name },
|
||||
deserializer = {
|
||||
try {
|
||||
enumValueOf(it)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
defaultValue
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
package ani.dantotsu.aniyomi.data
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import ani.dantotsu.MainActivity
|
||||
import ani.dantotsu.aniyomi.core.Constants
|
||||
/**
|
||||
* Global [BroadcastReceiver] that runs on UI thread
|
||||
* Pending Broadcasts should be made from here.
|
||||
* NOTE: Use local broadcasts if possible.
|
||||
*/
|
||||
class NotificationReceiver {
|
||||
|
||||
companion object {
|
||||
private const val NAME = "NotificationReceiver"
|
||||
|
||||
|
||||
/**
|
||||
* Returns [PendingIntent] that opens the extensions controller.
|
||||
*
|
||||
* @param context context of application
|
||||
* @return [PendingIntent]
|
||||
*/
|
||||
internal fun openExtensionsPendingActivity(context: Context): PendingIntent {
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
action = Constants.SHORTCUT_EXTENSIONS
|
||||
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
}
|
||||
return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
180
app/src/main/java/ani/dantotsu/aniyomi/data/Notifications.kt
Normal file
180
app/src/main/java/ani/dantotsu/aniyomi/data/Notifications.kt
Normal file
|
@ -0,0 +1,180 @@
|
|||
package ani.dantotsu.aniyomi.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.NotificationManagerCompat.IMPORTANCE_DEFAULT
|
||||
import androidx.core.app.NotificationManagerCompat.IMPORTANCE_HIGH
|
||||
import androidx.core.app.NotificationManagerCompat.IMPORTANCE_LOW
|
||||
import ani.dantotsu.aniyomi.util.system.buildNotificationChannel
|
||||
import ani.dantotsu.aniyomi.util.system.buildNotificationChannelGroup
|
||||
|
||||
/**
|
||||
* Class to manage the basic information of all the notifications used in the app.
|
||||
*/
|
||||
object Notifications {
|
||||
|
||||
/**
|
||||
* Common notification channel and ids used anywhere.
|
||||
*/
|
||||
const val CHANNEL_COMMON = "common_channel"
|
||||
const val ID_DOWNLOAD_IMAGE = 2
|
||||
|
||||
/**
|
||||
* Notification channel and ids used by the library updater.
|
||||
*/
|
||||
private const val GROUP_LIBRARY = "group_library"
|
||||
const val CHANNEL_LIBRARY_PROGRESS = "library_progress_channel"
|
||||
const val ID_LIBRARY_PROGRESS = -101
|
||||
const val ID_LIBRARY_SIZE_WARNING = -103
|
||||
const val CHANNEL_LIBRARY_ERROR = "library_errors_channel"
|
||||
const val ID_LIBRARY_ERROR = -102
|
||||
const val CHANNEL_LIBRARY_SKIPPED = "library_skipped_channel"
|
||||
const val ID_LIBRARY_SKIPPED = -104
|
||||
|
||||
/**
|
||||
* Notification channel and ids used by the downloader.
|
||||
*/
|
||||
private const val GROUP_DOWNLOADER = "group_downloader"
|
||||
const val CHANNEL_DOWNLOADER_PROGRESS = "downloader_progress_channel"
|
||||
const val ID_DOWNLOAD_CHAPTER_PROGRESS = -201
|
||||
const val ID_DOWNLOAD_EPISODE_PROGRESS = -203
|
||||
const val CHANNEL_DOWNLOADER_ERROR = "downloader_error_channel"
|
||||
const val ID_DOWNLOAD_CHAPTER_ERROR = -202
|
||||
const val ID_DOWNLOAD_EPISODE_ERROR = -204
|
||||
|
||||
/**
|
||||
* Notification channel and ids used by the library updater.
|
||||
*/
|
||||
const val CHANNEL_NEW_CHAPTERS_EPISODES = "new_chapters_episodes_channel"
|
||||
const val ID_NEW_CHAPTERS = -301
|
||||
const val ID_NEW_EPISODES = -1301
|
||||
const val GROUP_NEW_CHAPTERS = "eu.kanade.tachiyomi.NEW_CHAPTERS"
|
||||
const val GROUP_NEW_EPISODES = "eu.kanade.tachiyomi.NEW_EPISODES"
|
||||
|
||||
/**
|
||||
* Notification channel and ids used by the backup/restore system.
|
||||
*/
|
||||
private const val GROUP_BACKUP_RESTORE = "group_backup_restore"
|
||||
const val CHANNEL_BACKUP_RESTORE_PROGRESS = "backup_restore_progress_channel"
|
||||
const val ID_BACKUP_PROGRESS = -501
|
||||
const val ID_RESTORE_PROGRESS = -503
|
||||
const val CHANNEL_BACKUP_RESTORE_COMPLETE = "backup_restore_complete_channel_v2"
|
||||
const val ID_BACKUP_COMPLETE = -502
|
||||
const val ID_RESTORE_COMPLETE = -504
|
||||
|
||||
/**
|
||||
* Notification channel used for Incognito Mode
|
||||
*/
|
||||
const val CHANNEL_INCOGNITO_MODE = "incognito_mode_channel"
|
||||
const val ID_INCOGNITO_MODE = -701
|
||||
|
||||
/**
|
||||
* Notification channel and ids used for app and extension updates.
|
||||
*/
|
||||
private const val GROUP_APK_UPDATES = "group_apk_updates"
|
||||
const val CHANNEL_APP_UPDATE = "app_apk_update_channel"
|
||||
const val ID_APP_UPDATER = 1
|
||||
const val ID_APP_UPDATE_PROMPT = 2
|
||||
const val CHANNEL_EXTENSIONS_UPDATE = "ext_apk_update_channel"
|
||||
const val ID_UPDATES_TO_EXTS = -401
|
||||
const val ID_EXTENSION_INSTALLER = -402
|
||||
|
||||
private val deprecatedChannels = listOf(
|
||||
"downloader_channel",
|
||||
"downloader_complete_channel",
|
||||
"backup_restore_complete_channel",
|
||||
"library_channel",
|
||||
"library_progress_channel",
|
||||
"updates_ext_channel",
|
||||
"downloader_cache_renewal",
|
||||
"crash_logs_channel",
|
||||
)
|
||||
|
||||
/**
|
||||
* Creates the notification channels introduced in Android Oreo.
|
||||
* This won't do anything on Android versions that don't support notification channels.
|
||||
*
|
||||
* @param context The application context.
|
||||
*/
|
||||
fun createChannels(context: Context) {
|
||||
val notificationManager = NotificationManagerCompat.from(context)
|
||||
|
||||
// Delete old notification channels
|
||||
deprecatedChannels.forEach(notificationManager::deleteNotificationChannel)
|
||||
|
||||
notificationManager.createNotificationChannelGroupsCompat(
|
||||
listOf(
|
||||
buildNotificationChannelGroup(GROUP_BACKUP_RESTORE) {
|
||||
setName("Backup & Restore")
|
||||
},
|
||||
buildNotificationChannelGroup(GROUP_DOWNLOADER) {
|
||||
setName("Downloader")
|
||||
},
|
||||
buildNotificationChannelGroup(GROUP_LIBRARY) {
|
||||
setName("Library")
|
||||
},
|
||||
buildNotificationChannelGroup(GROUP_APK_UPDATES) {
|
||||
setName("App & Extension Updates")
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
notificationManager.createNotificationChannelsCompat(
|
||||
listOf(
|
||||
buildNotificationChannel(CHANNEL_COMMON, IMPORTANCE_LOW) {
|
||||
setName("Common")
|
||||
},
|
||||
buildNotificationChannel(CHANNEL_LIBRARY_PROGRESS, IMPORTANCE_LOW) {
|
||||
setName("Library Progress")
|
||||
setGroup(GROUP_LIBRARY)
|
||||
setShowBadge(false)
|
||||
},
|
||||
buildNotificationChannel(CHANNEL_LIBRARY_ERROR, IMPORTANCE_LOW) {
|
||||
setName("Library Errors")
|
||||
setGroup(GROUP_LIBRARY)
|
||||
setShowBadge(false)
|
||||
},
|
||||
buildNotificationChannel(CHANNEL_LIBRARY_SKIPPED, IMPORTANCE_LOW) {
|
||||
setName("Library Skipped")
|
||||
setGroup(GROUP_LIBRARY)
|
||||
setShowBadge(false)
|
||||
},
|
||||
buildNotificationChannel(CHANNEL_NEW_CHAPTERS_EPISODES, IMPORTANCE_DEFAULT) {
|
||||
setName("New Chapters & Episodes")
|
||||
},
|
||||
buildNotificationChannel(CHANNEL_DOWNLOADER_PROGRESS, IMPORTANCE_LOW) {
|
||||
setName("Downloader Progress")
|
||||
setGroup(GROUP_DOWNLOADER)
|
||||
setShowBadge(false)
|
||||
},
|
||||
buildNotificationChannel(CHANNEL_DOWNLOADER_ERROR, IMPORTANCE_LOW) {
|
||||
setName("Downloader Errors")
|
||||
setGroup(GROUP_DOWNLOADER)
|
||||
setShowBadge(false)
|
||||
},
|
||||
buildNotificationChannel(CHANNEL_BACKUP_RESTORE_PROGRESS, IMPORTANCE_LOW) {
|
||||
setName("Backup & Restore Progress")
|
||||
setGroup(GROUP_BACKUP_RESTORE)
|
||||
setShowBadge(false)
|
||||
},
|
||||
buildNotificationChannel(CHANNEL_BACKUP_RESTORE_COMPLETE, IMPORTANCE_HIGH) {
|
||||
setName("Backup & Restore Complete")
|
||||
setGroup(GROUP_BACKUP_RESTORE)
|
||||
setShowBadge(false)
|
||||
setSound(null, null)
|
||||
},
|
||||
buildNotificationChannel(CHANNEL_INCOGNITO_MODE, IMPORTANCE_LOW) {
|
||||
setName("Incognito Mode")
|
||||
},
|
||||
buildNotificationChannel(CHANNEL_APP_UPDATE, IMPORTANCE_DEFAULT) {
|
||||
setGroup(GROUP_APK_UPDATES)
|
||||
setName("App Updates")
|
||||
},
|
||||
buildNotificationChannel(CHANNEL_EXTENSIONS_UPDATE, IMPORTANCE_DEFAULT) {
|
||||
setGroup(GROUP_APK_UPDATES)
|
||||
setName("Extension Updates")
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
package ani.dantotsu.aniyomi.domain.base
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import ani.dantotsu.aniyomi.core.preference.PreferenceStore
|
||||
|
||||
class BasePreferences(
|
||||
val context: Context,
|
||||
private val preferenceStore: PreferenceStore,
|
||||
) {
|
||||
|
||||
fun confirmExit() = preferenceStore.getBoolean("pref_confirm_exit", false)
|
||||
|
||||
fun downloadedOnly() = preferenceStore.getBoolean("pref_downloaded_only", false)
|
||||
|
||||
fun incognitoMode() = preferenceStore.getBoolean("incognito_mode", false)
|
||||
|
||||
fun extensionInstaller() = ExtensionInstallerPreference(context, preferenceStore)
|
||||
|
||||
fun acraEnabled() = preferenceStore.getBoolean("acra.enable", true)
|
||||
|
||||
fun deviceHasPip() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
|
||||
|
||||
enum class ExtensionInstaller(val titleResId: String) {
|
||||
LEGACY("Legacy"),
|
||||
PACKAGEINSTALLER("PackageInstaller"),
|
||||
SHIZUKU("Shizuku"),
|
||||
}
|
||||
}
|
|
@ -0,0 +1,70 @@
|
|||
package ani.dantotsu.aniyomi.domain.base
|
||||
|
||||
import android.content.Context
|
||||
import ani.dantotsu.aniyomi.util.system.hasMiuiPackageInstaller
|
||||
import ani.dantotsu.aniyomi.domain.base.BasePreferences.ExtensionInstaller
|
||||
import ani.dantotsu.aniyomi.util.system.isShizukuInstalled
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import ani.dantotsu.aniyomi.core.preference.Preference
|
||||
import ani.dantotsu.aniyomi.core.preference.PreferenceStore
|
||||
import ani.dantotsu.aniyomi.core.preference.getEnum
|
||||
|
||||
class ExtensionInstallerPreference(
|
||||
private val context: Context,
|
||||
preferenceStore: PreferenceStore
|
||||
) : Preference<ExtensionInstaller> {
|
||||
|
||||
private val basePref = preferenceStore.getEnum(key(), defaultValue())
|
||||
|
||||
override fun key() = "extension_installer"
|
||||
|
||||
|
||||
|
||||
val entries get() = BasePreferences.ExtensionInstaller.values().run {
|
||||
if (context.hasMiuiPackageInstaller) {
|
||||
filter { it != BasePreferences.ExtensionInstaller.PACKAGEINSTALLER }
|
||||
} else {
|
||||
toList()
|
||||
}
|
||||
}
|
||||
|
||||
override fun defaultValue() = if (context.hasMiuiPackageInstaller) {
|
||||
ExtensionInstaller.LEGACY
|
||||
} else {
|
||||
ExtensionInstaller.PACKAGEINSTALLER
|
||||
}
|
||||
|
||||
private fun check(value: ExtensionInstaller): ExtensionInstaller {
|
||||
when (value) {
|
||||
ExtensionInstaller.PACKAGEINSTALLER -> {
|
||||
if (context.hasMiuiPackageInstaller) return ExtensionInstaller.LEGACY
|
||||
}
|
||||
ExtensionInstaller.SHIZUKU -> {
|
||||
if (!context.isShizukuInstalled) return defaultValue()
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
override fun get(): ExtensionInstaller {
|
||||
val value = basePref.get()
|
||||
val checkedValue = check(value)
|
||||
if (value != checkedValue) {
|
||||
basePref.set(checkedValue)
|
||||
}
|
||||
return checkedValue
|
||||
}
|
||||
|
||||
override fun set(value: ExtensionInstaller) {
|
||||
basePref.set(check(value))
|
||||
}
|
||||
|
||||
override fun isSet() = basePref.isSet()
|
||||
|
||||
override fun delete() = basePref.delete()
|
||||
|
||||
override fun changes() = basePref.changes()
|
||||
|
||||
override fun stateIn(scope: CoroutineScope) = basePref.stateIn(scope)
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
package ani.dantotsu.aniyomi.domain.category.model
|
||||
|
||||
import java.io.Serializable
|
||||
|
||||
data class Category(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val order: Long,
|
||||
val flags: Long,
|
||||
val hidden: Boolean,
|
||||
) : Serializable {
|
||||
|
||||
val isSystemCategory: Boolean = id == UNCATEGORIZED_ID
|
||||
|
||||
companion object {
|
||||
const val UNCATEGORIZED_ID = 0L
|
||||
}
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
package ani.dantotsu.aniyomi.domain.library.model
|
||||
|
||||
interface Flag {
|
||||
val flag: Long
|
||||
}
|
||||
|
||||
interface Mask {
|
||||
val mask: Long
|
||||
}
|
||||
|
||||
interface FlagWithMask : Flag, Mask
|
||||
|
||||
operator fun Long.contains(other: Flag): Boolean {
|
||||
return if (other is Mask) {
|
||||
other.flag == this and other.mask
|
||||
} else {
|
||||
other.flag == this
|
||||
}
|
||||
}
|
||||
|
||||
operator fun Long.plus(other: Flag): Long {
|
||||
return if (other is Mask) {
|
||||
this and other.mask.inv() or (other.flag and other.mask)
|
||||
} else {
|
||||
this or other.flag
|
||||
}
|
||||
}
|
||||
|
||||
operator fun Flag.plus(other: Flag): Long {
|
||||
return if (other is Mask) {
|
||||
this.flag and other.mask.inv() or (other.flag and other.mask)
|
||||
} else {
|
||||
this.flag or other.flag
|
||||
}
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
package ani.dantotsu.aniyomi.domain.library.model
|
||||
|
||||
import ani.dantotsu.aniyomi.domain.category.model.Category
|
||||
|
||||
sealed class LibraryDisplayMode(
|
||||
override val flag: Long,
|
||||
) : FlagWithMask {
|
||||
|
||||
override val mask: Long = 0b00000011L
|
||||
|
||||
object CompactGrid : LibraryDisplayMode(0b00000000)
|
||||
object ComfortableGrid : LibraryDisplayMode(0b00000001)
|
||||
object List : LibraryDisplayMode(0b00000010)
|
||||
object CoverOnlyGrid : LibraryDisplayMode(0b00000011)
|
||||
|
||||
object Serializer {
|
||||
fun deserialize(serialized: String): LibraryDisplayMode {
|
||||
return Companion.deserialize(serialized)
|
||||
}
|
||||
|
||||
fun serialize(value: LibraryDisplayMode): String {
|
||||
return value.serialize()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val values by lazy { setOf(CompactGrid, ComfortableGrid, List, CoverOnlyGrid) }
|
||||
val default = CompactGrid
|
||||
|
||||
fun valueOf(flag: Long?): LibraryDisplayMode {
|
||||
if (flag == null) return default
|
||||
return values
|
||||
.find { mode -> mode.flag == flag and mode.mask }
|
||||
?: default
|
||||
}
|
||||
|
||||
fun deserialize(serialized: String): LibraryDisplayMode {
|
||||
return when (serialized) {
|
||||
"COMFORTABLE_GRID" -> ComfortableGrid
|
||||
"COMPACT_GRID" -> CompactGrid
|
||||
"COVER_ONLY_GRID" -> CoverOnlyGrid
|
||||
"LIST" -> List
|
||||
else -> default
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun serialize(): String {
|
||||
return when (this) {
|
||||
ComfortableGrid -> "COMFORTABLE_GRID"
|
||||
CompactGrid -> "COMPACT_GRID"
|
||||
CoverOnlyGrid -> "COVER_ONLY_GRID"
|
||||
List -> "LIST"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val Category?.display: LibraryDisplayMode
|
||||
get() = LibraryDisplayMode.valueOf(this?.flags)
|
|
@ -0,0 +1,10 @@
|
|||
package ani.dantotsu.aniyomi.domain.source.anime.model
|
||||
|
||||
data class AnimeSourceData(
|
||||
val id: Long,
|
||||
val lang: String,
|
||||
val name: String,
|
||||
) {
|
||||
|
||||
val isMissingInfo: Boolean = name.isBlank() || lang.isBlank()
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
package ani.dantotsu.aniyomi.domain.source.service
|
||||
|
||||
class SetMigrateSorting(
|
||||
private val preferences: SourcePreferences,
|
||||
) {
|
||||
|
||||
fun await(mode: Mode, direction: Direction) {
|
||||
preferences.migrationSortingMode().set(mode)
|
||||
preferences.migrationSortingDirection().set(direction)
|
||||
}
|
||||
|
||||
enum class Mode {
|
||||
ALPHABETICAL,
|
||||
TOTAL,
|
||||
}
|
||||
|
||||
enum class Direction {
|
||||
ASCENDING,
|
||||
DESCENDING,
|
||||
}
|
||||
}
|
|
@ -0,0 +1,82 @@
|
|||
package ani.dantotsu.aniyomi.domain.source.service
|
||||
|
||||
import ani.dantotsu.aniyomi.core.preference.PreferenceStore
|
||||
import ani.dantotsu.aniyomi.util.system.LocaleHelper
|
||||
import ani.dantotsu.aniyomi.core.preference.getEnum
|
||||
import ani.dantotsu.aniyomi.domain.library.model.LibraryDisplayMode
|
||||
|
||||
class SourcePreferences(
|
||||
private val preferenceStore: PreferenceStore,
|
||||
) {
|
||||
|
||||
// Common options
|
||||
|
||||
fun sourceDisplayMode() = preferenceStore.getObject("pref_display_mode_catalogue", LibraryDisplayMode.default, LibraryDisplayMode.Serializer::serialize, LibraryDisplayMode.Serializer::deserialize)
|
||||
|
||||
fun enabledLanguages() = preferenceStore.getStringSet("source_languages", LocaleHelper.getDefaultEnabledLanguages())
|
||||
|
||||
fun showNsfwSource() = preferenceStore.getBoolean("show_nsfw_source", true)
|
||||
|
||||
fun migrationSortingMode() = preferenceStore.getEnum("pref_migration_sorting", SetMigrateSorting.Mode.ALPHABETICAL)
|
||||
|
||||
fun migrationSortingDirection() = preferenceStore.getEnum("pref_migration_direction", SetMigrateSorting.Direction.ASCENDING)
|
||||
|
||||
fun trustedSignatures() = preferenceStore.getStringSet("trusted_signatures", emptySet())
|
||||
|
||||
// Mixture Sources
|
||||
|
||||
fun disabledAnimeSources() = preferenceStore.getStringSet("hidden_anime_catalogues", emptySet())
|
||||
fun disabledMangaSources() = preferenceStore.getStringSet("hidden_catalogues", emptySet())
|
||||
|
||||
fun pinnedAnimeSources() = preferenceStore.getStringSet("pinned_anime_catalogues", emptySet())
|
||||
fun pinnedMangaSources() = preferenceStore.getStringSet("pinned_catalogues", emptySet())
|
||||
|
||||
fun lastUsedAnimeSource() = preferenceStore.getLong("last_anime_catalogue_source", -1)
|
||||
fun lastUsedMangaSource() = preferenceStore.getLong("last_catalogue_source", -1)
|
||||
|
||||
fun animeExtensionUpdatesCount() = preferenceStore.getInt("animeext_updates_count", 0)
|
||||
fun mangaExtensionUpdatesCount() = preferenceStore.getInt("ext_updates_count", 0)
|
||||
|
||||
fun searchPinnedAnimeSourcesOnly() = preferenceStore.getBoolean("search_pinned_anime_sources_only", false)
|
||||
fun searchPinnedMangaSourcesOnly() = preferenceStore.getBoolean("search_pinned_sources_only", false)
|
||||
|
||||
fun hideInAnimeLibraryItems() = preferenceStore.getBoolean("browse_hide_in_anime_library_items", false)
|
||||
|
||||
fun hideInMangaLibraryItems() = preferenceStore.getBoolean("browse_hide_in_library_items", false)
|
||||
|
||||
// SY -->
|
||||
|
||||
// fun enableSourceBlacklist() = preferenceStore.getBoolean("eh_enable_source_blacklist", true)
|
||||
|
||||
// fun sourcesTabCategories() = preferenceStore.getStringSet("sources_tab_categories", mutableSetOf())
|
||||
|
||||
// fun sourcesTabCategoriesFilter() = preferenceStore.getBoolean("sources_tab_categories_filter", false)
|
||||
|
||||
// fun sourcesTabSourcesInCategories() = preferenceStore.getStringSet("sources_tab_source_categories", mutableSetOf())
|
||||
|
||||
fun dataSaver() = preferenceStore.getEnum("data_saver", DataSaver.NONE)
|
||||
|
||||
fun dataSaverIgnoreJpeg() = preferenceStore.getBoolean("ignore_jpeg", false)
|
||||
|
||||
fun dataSaverIgnoreGif() = preferenceStore.getBoolean("ignore_gif", true)
|
||||
|
||||
fun dataSaverImageQuality() = preferenceStore.getInt("data_saver_image_quality", 80)
|
||||
|
||||
fun dataSaverImageFormatJpeg() = preferenceStore.getBoolean("data_saver_image_format_jpeg", false)
|
||||
|
||||
fun dataSaverServer() = preferenceStore.getString("data_saver_server", "")
|
||||
|
||||
fun dataSaverColorBW() = preferenceStore.getBoolean("data_saver_color_bw", false)
|
||||
|
||||
fun dataSaverExcludedSources() = preferenceStore.getStringSet("data_saver_excluded", emptySet())
|
||||
|
||||
fun dataSaverDownloader() = preferenceStore.getBoolean("data_saver_downloader", true)
|
||||
|
||||
enum class DataSaver {
|
||||
NONE,
|
||||
BANDWIDTH_HERO,
|
||||
WSRV_NL,
|
||||
RESMUSH_IT,
|
||||
}
|
||||
// SY <--
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
package ani.dantotsu.aniyomi.source
|
||||
|
||||
import ani.dantotsu.aniyomi.source.model.FilterList
|
||||
import ani.dantotsu.aniyomi.source.model.MangasPage
|
||||
import rx.Observable
|
||||
|
||||
interface CatalogueSource : MangaSource {
|
||||
|
||||
/**
|
||||
* An ISO 639-1 compliant language code (two letters in lower case).
|
||||
*/
|
||||
override val lang: String
|
||||
|
||||
/**
|
||||
* Whether the source has support for latest updates.
|
||||
*/
|
||||
val supportsLatest: Boolean
|
||||
|
||||
/**
|
||||
* Returns an observable containing a page with a list of manga.
|
||||
*
|
||||
* @param page the page number to retrieve.
|
||||
*/
|
||||
fun fetchPopularManga(page: Int): Observable<MangasPage>
|
||||
|
||||
/**
|
||||
* Returns an observable containing a page with a list of manga.
|
||||
*
|
||||
* @param page the page number to retrieve.
|
||||
* @param query the search query.
|
||||
* @param filters the list of filters to apply.
|
||||
*/
|
||||
fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage>
|
||||
|
||||
/**
|
||||
* Returns an observable containing a page with a list of latest manga updates.
|
||||
*
|
||||
* @param page the page number to retrieve.
|
||||
*/
|
||||
fun fetchLatestUpdates(page: Int): Observable<MangasPage>
|
||||
|
||||
/**
|
||||
* Returns the list of filters for the source.
|
||||
*/
|
||||
fun getFilterList(): FilterList
|
||||
}
|
85
app/src/main/java/ani/dantotsu/aniyomi/source/MangaSource.kt
Normal file
85
app/src/main/java/ani/dantotsu/aniyomi/source/MangaSource.kt
Normal file
|
@ -0,0 +1,85 @@
|
|||
package ani.dantotsu.aniyomi.source
|
||||
|
||||
import ani.dantotsu.aniyomi.source.model.Page
|
||||
import ani.dantotsu.aniyomi.source.model.SChapter
|
||||
import ani.dantotsu.aniyomi.source.model.SManga
|
||||
import ani.dantotsu.aniyomi.util.lang.awaitSingle
|
||||
import rx.Observable
|
||||
|
||||
/**
|
||||
* A basic interface for creating a source. It could be an online source, a local source, etc.
|
||||
*/
|
||||
interface MangaSource {
|
||||
|
||||
/**
|
||||
* ID for the source. Must be unique.
|
||||
*/
|
||||
val id: Long
|
||||
|
||||
/**
|
||||
* Name of the source.
|
||||
*/
|
||||
val name: String
|
||||
|
||||
val lang: String
|
||||
get() = ""
|
||||
|
||||
/**
|
||||
* Returns an observable with the updated details for a manga.
|
||||
*
|
||||
* @param manga the manga to update.
|
||||
*/
|
||||
@Deprecated(
|
||||
"Use the 1.x API instead",
|
||||
ReplaceWith("getMangaDetails"),
|
||||
)
|
||||
fun fetchMangaDetails(manga: SManga): Observable<SManga> = throw IllegalStateException("Not used")
|
||||
|
||||
/**
|
||||
* Returns an observable with all the available chapters for a manga.
|
||||
*
|
||||
* @param manga the manga to update.
|
||||
*/
|
||||
@Deprecated(
|
||||
"Use the 1.x API instead",
|
||||
ReplaceWith("getChapterList"),
|
||||
)
|
||||
fun fetchChapterList(manga: SManga): Observable<List<SChapter>> = throw IllegalStateException("Not used")
|
||||
|
||||
/**
|
||||
* Returns an observable with the list of pages a chapter has. Pages should be returned
|
||||
* in the expected order; the index is ignored.
|
||||
*
|
||||
* @param chapter the chapter.
|
||||
*/
|
||||
@Deprecated(
|
||||
"Use the 1.x API instead",
|
||||
ReplaceWith("getPageList"),
|
||||
)
|
||||
fun fetchPageList(chapter: SChapter): Observable<List<Page>> = Observable.empty()
|
||||
|
||||
/**
|
||||
* [1.x API] Get the updated details for a manga.
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
suspend fun getMangaDetails(manga: SManga): SManga {
|
||||
return fetchMangaDetails(manga).awaitSingle()
|
||||
}
|
||||
|
||||
/**
|
||||
* [1.x API] Get all the available chapters for a manga.
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
suspend fun getChapterList(manga: SManga): List<SChapter> {
|
||||
return fetchChapterList(manga).awaitSingle()
|
||||
}
|
||||
|
||||
/**
|
||||
* [1.x API] Get the list of pages a chapter has. Pages should be returned
|
||||
* in the expected order; the index is ignored.
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
suspend fun getPageList(chapter: SChapter): List<Page> {
|
||||
return fetchPageList(chapter).awaitSingle()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
package ani.dantotsu.aniyomi.source.model
|
||||
|
||||
sealed class Filter<T>(val name: String, var state: T) {
|
||||
open class Header(name: String) : Filter<Any>(name, 0)
|
||||
open class Separator(name: String = "") : Filter<Any>(name, 0)
|
||||
abstract class Select<V>(name: String, val values: Array<V>, state: Int = 0) : Filter<Int>(name, state)
|
||||
abstract class Text(name: String, state: String = "") : Filter<String>(name, state)
|
||||
abstract class CheckBox(name: String, state: Boolean = false) : Filter<Boolean>(name, state)
|
||||
abstract class TriState(name: String, state: Int = STATE_IGNORE) : Filter<Int>(name, state) {
|
||||
fun isIgnored() = state == STATE_IGNORE
|
||||
fun isIncluded() = state == STATE_INCLUDE
|
||||
fun isExcluded() = state == STATE_EXCLUDE
|
||||
|
||||
companion object {
|
||||
const val STATE_IGNORE = 0
|
||||
const val STATE_INCLUDE = 1
|
||||
const val STATE_EXCLUDE = 2
|
||||
}
|
||||
}
|
||||
|
||||
abstract class Group<V>(name: String, state: List<V>) : Filter<List<V>>(name, state)
|
||||
|
||||
abstract class Sort(name: String, val values: Array<String>, state: Selection? = null) :
|
||||
Filter<Sort.Selection?>(name, state) {
|
||||
data class Selection(val index: Int, val ascending: Boolean)
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is Filter<*>) return false
|
||||
|
||||
return name == other.name && state == other.state
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = name.hashCode()
|
||||
result = 31 * result + (state?.hashCode() ?: 0)
|
||||
return result
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
package ani.dantotsu.aniyomi.source.model
|
||||
|
||||
data class FilterList(val list: List<Filter<*>>) : List<Filter<*>> by list {
|
||||
|
||||
constructor(vararg fs: Filter<*>) : this(if (fs.isNotEmpty()) fs.asList() else emptyList())
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return list.hashCode()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
package ani.dantotsu.aniyomi.source.model
|
||||
|
||||
data class MangasPage(val mangas: List<SManga>, val hasNextPage: Boolean)
|
58
app/src/main/java/ani/dantotsu/aniyomi/source/model/Page.kt
Normal file
58
app/src/main/java/ani/dantotsu/aniyomi/source/model/Page.kt
Normal file
|
@ -0,0 +1,58 @@
|
|||
package ani.dantotsu.aniyomi.source.model
|
||||
|
||||
import android.net.Uri
|
||||
import ani.dantotsu.aniyomi.util.network.ProgressListener
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
|
||||
@Serializable
|
||||
open class Page(
|
||||
val index: Int,
|
||||
val url: String = "",
|
||||
var imageUrl: String? = null,
|
||||
@Transient var uri: Uri? = null, // Deprecated but can't be deleted due to extensions
|
||||
) : ProgressListener {
|
||||
|
||||
val number: Int
|
||||
get() = index + 1
|
||||
|
||||
@Transient
|
||||
private val _statusFlow = MutableStateFlow(State.QUEUE)
|
||||
|
||||
@Transient
|
||||
val statusFlow = _statusFlow.asStateFlow()
|
||||
var status: State
|
||||
get() = _statusFlow.value
|
||||
set(value) {
|
||||
_statusFlow.value = value
|
||||
}
|
||||
|
||||
@Transient
|
||||
private val _progressFlow = MutableStateFlow(0)
|
||||
|
||||
@Transient
|
||||
val progressFlow = _progressFlow.asStateFlow()
|
||||
var progress: Int
|
||||
get() = _progressFlow.value
|
||||
set(value) {
|
||||
_progressFlow.value = value
|
||||
}
|
||||
|
||||
override fun update(bytesRead: Long, contentLength: Long, done: Boolean) {
|
||||
progress = if (contentLength > 0) {
|
||||
(100 * bytesRead / contentLength).toInt()
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
}
|
||||
|
||||
enum class State {
|
||||
QUEUE,
|
||||
LOAD_PAGE,
|
||||
DOWNLOAD_IMAGE,
|
||||
READY,
|
||||
ERROR,
|
||||
}
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
package ani.dantotsu.aniyomi.source.model
|
||||
|
||||
import java.io.Serializable
|
||||
|
||||
interface SChapter : Serializable {
|
||||
|
||||
var url: String
|
||||
|
||||
var name: String
|
||||
|
||||
var date_upload: Long
|
||||
|
||||
var chapter_number: Float
|
||||
|
||||
var scanlator: String?
|
||||
|
||||
fun copyFrom(other: SChapter) {
|
||||
name = other.name
|
||||
url = other.url
|
||||
date_upload = other.date_upload
|
||||
chapter_number = other.chapter_number
|
||||
scanlator = other.scanlator
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun create(): SChapter {
|
||||
return SChapterImpl()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
package ani.dantotsu.aniyomi.source.model
|
||||
|
||||
class SChapterImpl : SChapter {
|
||||
|
||||
override lateinit var url: String
|
||||
|
||||
override lateinit var name: String
|
||||
|
||||
override var date_upload: Long = 0
|
||||
|
||||
override var chapter_number: Float = -1f
|
||||
|
||||
override var scanlator: String? = null
|
||||
}
|
|
@ -0,0 +1,88 @@
|
|||
package ani.dantotsu.aniyomi.source.model
|
||||
|
||||
import java.io.Serializable
|
||||
|
||||
interface SManga : Serializable {
|
||||
|
||||
var url: String
|
||||
|
||||
var title: String
|
||||
|
||||
var artist: String?
|
||||
|
||||
var author: String?
|
||||
|
||||
var description: String?
|
||||
|
||||
var genre: String?
|
||||
|
||||
var status: Int
|
||||
|
||||
var thumbnail_url: String?
|
||||
|
||||
var update_strategy: UpdateStrategy
|
||||
|
||||
var initialized: Boolean
|
||||
|
||||
fun getGenres(): List<String>? {
|
||||
if (genre.isNullOrBlank()) return null
|
||||
return genre?.split(", ")?.map { it.trim() }?.filterNot { it.isBlank() }?.distinct()
|
||||
}
|
||||
|
||||
fun copyFrom(other: SManga) {
|
||||
if (other.author != null) {
|
||||
author = other.author
|
||||
}
|
||||
|
||||
if (other.artist != null) {
|
||||
artist = other.artist
|
||||
}
|
||||
|
||||
if (other.description != null) {
|
||||
description = other.description
|
||||
}
|
||||
|
||||
if (other.genre != null) {
|
||||
genre = other.genre
|
||||
}
|
||||
|
||||
if (other.thumbnail_url != null) {
|
||||
thumbnail_url = other.thumbnail_url
|
||||
}
|
||||
|
||||
status = other.status
|
||||
|
||||
update_strategy = other.update_strategy
|
||||
|
||||
if (!initialized) {
|
||||
initialized = other.initialized
|
||||
}
|
||||
}
|
||||
|
||||
fun copy() = create().also {
|
||||
it.url = url
|
||||
it.title = title
|
||||
it.artist = artist
|
||||
it.author = author
|
||||
it.description = description
|
||||
it.genre = genre
|
||||
it.status = status
|
||||
it.thumbnail_url = thumbnail_url
|
||||
it.update_strategy = update_strategy
|
||||
it.initialized = initialized
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val UNKNOWN = 0
|
||||
const val ONGOING = 1
|
||||
const val COMPLETED = 2
|
||||
const val LICENSED = 3
|
||||
const val PUBLISHING_FINISHED = 4
|
||||
const val CANCELLED = 5
|
||||
const val ON_HIATUS = 6
|
||||
|
||||
fun create(): SManga {
|
||||
return SMangaImpl()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
package ani.dantotsu.aniyomi.source.model
|
||||
|
||||
class SMangaImpl : SManga {
|
||||
|
||||
override lateinit var url: String
|
||||
|
||||
override lateinit var title: String
|
||||
|
||||
override var artist: String? = null
|
||||
|
||||
override var author: String? = null
|
||||
|
||||
override var description: String? = null
|
||||
|
||||
override var genre: String? = null
|
||||
|
||||
override var status: Int = 0
|
||||
|
||||
override var thumbnail_url: String? = null
|
||||
|
||||
override var update_strategy: UpdateStrategy = UpdateStrategy.ALWAYS_UPDATE
|
||||
|
||||
override var initialized: Boolean = false
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
package ani.dantotsu.aniyomi.source.model
|
||||
|
||||
/**
|
||||
* Define the update strategy for a single [SManga].
|
||||
* The strategy used will only take effect on the library update.
|
||||
*
|
||||
* @since extensions-lib 1.4
|
||||
*/
|
||||
enum class UpdateStrategy {
|
||||
/**
|
||||
* Series marked as always update will be included in the library
|
||||
* update if they aren't excluded by additional restrictions.
|
||||
*/
|
||||
ALWAYS_UPDATE,
|
||||
|
||||
/**
|
||||
* Series marked as only fetch once will be automatically skipped
|
||||
* during library updates. Useful for cases where the series is previously
|
||||
* known to be finished and have only a single chapter, for example.
|
||||
*/
|
||||
ONLY_FETCH_ONCE,
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
package ani.dantotsu.aniyomi.util
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Think twice before using this. This is a delicate API. It is easy to accidentally create resource or memory leaks when GlobalScope is used.
|
||||
*
|
||||
* **Possible replacements**
|
||||
* - suspend function
|
||||
* - custom scope like view or presenter scope
|
||||
*/
|
||||
@DelicateCoroutinesApi
|
||||
fun launchUI(block: suspend CoroutineScope.() -> Unit): Job =
|
||||
GlobalScope.launch(Dispatchers.Main, CoroutineStart.DEFAULT, block)
|
||||
|
||||
/**
|
||||
* Think twice before using this. This is a delicate API. It is easy to accidentally create resource or memory leaks when GlobalScope is used.
|
||||
*
|
||||
* **Possible replacements**
|
||||
* - suspend function
|
||||
* - custom scope like view or presenter scope
|
||||
*/
|
||||
@DelicateCoroutinesApi
|
||||
fun launchIO(block: suspend CoroutineScope.() -> Unit): Job =
|
||||
GlobalScope.launch(Dispatchers.IO, CoroutineStart.DEFAULT, block)
|
||||
|
||||
/**
|
||||
* Think twice before using this. This is a delicate API. It is easy to accidentally create resource or memory leaks when GlobalScope is used.
|
||||
*
|
||||
* **Possible replacements**
|
||||
* - suspend function
|
||||
* - custom scope like view or presenter scope
|
||||
*/
|
||||
@DelicateCoroutinesApi
|
||||
fun launchNow(block: suspend CoroutineScope.() -> Unit): Job =
|
||||
GlobalScope.launch(Dispatchers.Main, CoroutineStart.UNDISPATCHED, block)
|
||||
|
||||
fun CoroutineScope.launchUI(block: suspend CoroutineScope.() -> Unit): Job =
|
||||
launch(Dispatchers.Main, block = block)
|
||||
|
||||
fun CoroutineScope.launchIO(block: suspend CoroutineScope.() -> Unit): Job =
|
||||
launch(Dispatchers.IO, block = block)
|
||||
|
||||
fun CoroutineScope.launchNonCancellable(block: suspend CoroutineScope.() -> Unit): Job =
|
||||
launchIO { withContext(NonCancellable, block) }
|
||||
|
||||
suspend fun <T> withUIContext(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.Main, block)
|
||||
|
||||
suspend fun <T> withIOContext(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.IO, block)
|
||||
|
||||
suspend fun <T> withNonCancellableContext(block: suspend CoroutineScope.() -> T) =
|
||||
withContext(NonCancellable, block)
|
|
@ -0,0 +1,26 @@
|
|||
package eu.kanade.tachiyomi.util
|
||||
|
||||
import okhttp3.Response
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
|
||||
fun Element.selectText(css: String, defaultValue: String? = null): String? {
|
||||
return select(css).first()?.text() ?: defaultValue
|
||||
}
|
||||
|
||||
fun Element.selectInt(css: String, defaultValue: Int = 0): Int {
|
||||
return select(css).first()?.text()?.toInt() ?: defaultValue
|
||||
}
|
||||
|
||||
fun Element.attrOrText(css: String): String {
|
||||
return if (css != "text") attr(css) else text()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Jsoup document for this response.
|
||||
* @param html the body of the response. Use only if the body was read before calling this method.
|
||||
*/
|
||||
fun Response.asJsoup(html: String? = null): Document {
|
||||
return Jsoup.parse(html ?: body.string(), request.url.toString())
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
package ani.dantotsu.aniyomi.util
|
||||
|
||||
import logcat.LogPriority
|
||||
import logcat.asLog
|
||||
import logcat.logcat
|
||||
|
||||
inline fun Any.logcat(
|
||||
priority: LogPriority = LogPriority.DEBUG,
|
||||
throwable: Throwable? = null,
|
||||
message: () -> String = { "" },
|
||||
) = logcat(priority = priority) {
|
||||
var msg = message()
|
||||
if (throwable != null) {
|
||||
if (msg.isNotBlank()) msg += "\n"
|
||||
msg += throwable.asLog()
|
||||
}
|
||||
msg
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
package ani.dantotsu.aniyomi.util
|
||||
|
||||
//expect suspend fun <T> Observable<T>.awaitSingle(): T
|
|
@ -0,0 +1,28 @@
|
|||
package ani.dantotsu.aniyomi.util
|
||||
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.StringRes
|
||||
|
||||
/**
|
||||
* Display a toast in this context.
|
||||
*
|
||||
* @param resource the text resource.
|
||||
* @param duration the duration of the toast. Defaults to short.
|
||||
*/
|
||||
fun Context.toast(@StringRes resource: Int, duration: Int = Toast.LENGTH_SHORT, block: (Toast) -> Unit = {}): Toast {
|
||||
return toast(getString(resource), duration, block)
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a toast in this context.
|
||||
*
|
||||
* @param text the text to display.
|
||||
* @param duration the duration of the toast. Defaults to short.
|
||||
*/
|
||||
fun Context.toast(text: String?, duration: Int = Toast.LENGTH_SHORT, block: (Toast) -> Unit = {}): Toast {
|
||||
return Toast.makeText(applicationContext, text.orEmpty(), duration).also {
|
||||
block(it)
|
||||
it.show()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
package ani.dantotsu.aniyomi.util.extension
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.app.NotificationCompat
|
||||
import ani.dantotsu.R
|
||||
import ani.dantotsu.aniyomi.data.NotificationReceiver
|
||||
import ani.dantotsu.aniyomi.data.Notifications
|
||||
import ani.dantotsu.aniyomi.util.system.notify
|
||||
|
||||
class ExtensionUpdateNotifier(private val context: Context) {
|
||||
|
||||
fun promptUpdates(names: List<String>) {
|
||||
context.notify(
|
||||
Notifications.ID_UPDATES_TO_EXTS,
|
||||
Notifications.CHANNEL_EXTENSIONS_UPDATE,
|
||||
) {
|
||||
setContentTitle(
|
||||
"Extension updates available"
|
||||
)
|
||||
val extNames = names.joinToString(", ")
|
||||
setContentText(extNames)
|
||||
setStyle(NotificationCompat.BigTextStyle().bigText(extNames))
|
||||
setSmallIcon(R.drawable.ic_round_favorite_24)
|
||||
setContentIntent(NotificationReceiver.openExtensionsPendingActivity(context))
|
||||
setAutoCancel(true)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package ani.dantotsu.aniyomi.util.extension
|
||||
|
||||
enum class InstallStep {
|
||||
Idle, Pending, Downloading, Installing, Installed, Error;
|
||||
|
||||
fun isCompleted(): Boolean {
|
||||
return this == Installed || this == Error || this == Idle
|
||||
}
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
package ani.dantotsu.aniyomi.util.lang
|
||||
|
||||
import java.io.Closeable
|
||||
|
||||
/**
|
||||
* Executes the given block function on this resources and then closes it down correctly whether an exception is
|
||||
* thrown or not.
|
||||
*
|
||||
* @param block a function to process with given Closeable resources.
|
||||
* @return the result of block function invoked on this resource.
|
||||
*/
|
||||
inline fun <T : Closeable?> Array<T>.use(block: () -> Unit) {
|
||||
var blockException: Throwable? = null
|
||||
try {
|
||||
return block()
|
||||
} catch (e: Throwable) {
|
||||
blockException = e
|
||||
throw e
|
||||
} finally {
|
||||
when (blockException) {
|
||||
null -> forEach { it?.close() }
|
||||
else -> forEach {
|
||||
try {
|
||||
it?.close()
|
||||
} catch (closeException: Throwable) {
|
||||
blockException.addSuppressed(closeException)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
44
app/src/main/java/ani/dantotsu/aniyomi/util/lang/Hash.kt
Normal file
44
app/src/main/java/ani/dantotsu/aniyomi/util/lang/Hash.kt
Normal file
|
@ -0,0 +1,44 @@
|
|||
package ani.dantotsu.aniyomi.util.lang
|
||||
|
||||
import java.security.MessageDigest
|
||||
|
||||
object Hash {
|
||||
|
||||
private val chars = charArrayOf(
|
||||
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
|
||||
'a', 'b', 'c', 'd', 'e', 'f',
|
||||
)
|
||||
|
||||
private val MD5 get() = MessageDigest.getInstance("MD5")
|
||||
|
||||
private val SHA256 get() = MessageDigest.getInstance("SHA-256")
|
||||
|
||||
fun sha256(bytes: ByteArray): String {
|
||||
return encodeHex(SHA256.digest(bytes))
|
||||
}
|
||||
|
||||
fun sha256(string: String): String {
|
||||
return sha256(string.toByteArray())
|
||||
}
|
||||
|
||||
fun md5(bytes: ByteArray): String {
|
||||
return encodeHex(MD5.digest(bytes))
|
||||
}
|
||||
|
||||
fun md5(string: String): String {
|
||||
return md5(string.toByteArray())
|
||||
}
|
||||
|
||||
private fun encodeHex(data: ByteArray): String {
|
||||
val l = data.size
|
||||
val out = CharArray(l shl 1)
|
||||
var i = 0
|
||||
var j = 0
|
||||
while (i < l) {
|
||||
out[j++] = chars[(240 and data[i].toInt()).ushr(4)]
|
||||
out[j++] = chars[15 and data[i].toInt()]
|
||||
i++
|
||||
}
|
||||
return String(out)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
package ani.dantotsu.aniyomi.util.lang
|
||||
|
||||
import kotlinx.coroutines.CancellableContinuation
|
||||
import kotlinx.coroutines.InternalCoroutinesApi
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import rx.Observable
|
||||
import rx.Subscriber
|
||||
import rx.Subscription
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
/*
|
||||
* Util functions for bridging RxJava and coroutines. Taken from TachiyomiEH/SY.
|
||||
*/
|
||||
|
||||
suspend fun <T> Observable<T>.awaitSingle(): T = single().awaitOne()
|
||||
|
||||
@OptIn(InternalCoroutinesApi::class)
|
||||
private suspend fun <T> Observable<T>.awaitOne(): T = suspendCancellableCoroutine { cont ->
|
||||
cont.unsubscribeOnCancellation(
|
||||
subscribe(
|
||||
object : Subscriber<T>() {
|
||||
override fun onStart() {
|
||||
request(1)
|
||||
}
|
||||
|
||||
override fun onNext(t: T) {
|
||||
cont.resume(t)
|
||||
}
|
||||
|
||||
override fun onCompleted() {
|
||||
if (cont.isActive) {
|
||||
cont.resumeWithException(
|
||||
IllegalStateException(
|
||||
"Should have invoked onNext",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(e: Throwable) {
|
||||
/*
|
||||
* Rx1 observable throws NoSuchElementException if cancellation happened before
|
||||
* element emission. To mitigate this we try to atomically resume continuation with exception:
|
||||
* if resume failed, then we know that continuation successfully cancelled itself
|
||||
*/
|
||||
val token = cont.tryResumeWithException(e)
|
||||
if (token != null) {
|
||||
cont.completeResume(token)
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun <T> CancellableContinuation<T>.unsubscribeOnCancellation(sub: Subscription) =
|
||||
invokeOnCancellation { sub.unsubscribe() }
|
|
@ -0,0 +1,67 @@
|
|||
package ani.dantotsu.aniyomi.util.lang
|
||||
|
||||
import androidx.core.text.parseAsHtml
|
||||
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
|
||||
import java.nio.charset.StandardCharsets
|
||||
import kotlin.math.floor
|
||||
|
||||
/**
|
||||
* Replaces the given string to have at most [count] characters using [replacement] at its end.
|
||||
* If [replacement] is longer than [count] an exception will be thrown when `length > count`.
|
||||
*/
|
||||
fun String.chop(count: Int, replacement: String = "…"): String {
|
||||
return if (length > count) {
|
||||
take(count - replacement.length) + replacement
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the given string to have at most [count] characters using [replacement] near the center.
|
||||
* If [replacement] is longer than [count] an exception will be thrown when `length > count`.
|
||||
*/
|
||||
fun String.truncateCenter(count: Int, replacement: String = "..."): String {
|
||||
if (length <= count) {
|
||||
return this
|
||||
}
|
||||
|
||||
val pieceLength: Int = floor((count - replacement.length).div(2.0)).toInt()
|
||||
|
||||
return "${take(pieceLength)}$replacement${takeLast(pieceLength)}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Case-insensitive natural comparator for strings.
|
||||
*/
|
||||
fun String.compareToCaseInsensitiveNaturalOrder(other: String): Int {
|
||||
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
|
||||
return comparator.compare(this, other)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the size of the string as the number of bytes.
|
||||
*/
|
||||
fun String.byteSize(): Int {
|
||||
return toByteArray(StandardCharsets.UTF_8).size
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a string containing the first [n] bytes from this string, or the entire string if this
|
||||
* string is shorter.
|
||||
*/
|
||||
fun String.takeBytes(n: Int): String {
|
||||
val bytes = toByteArray(StandardCharsets.UTF_8)
|
||||
return if (bytes.size <= n) {
|
||||
this
|
||||
} else {
|
||||
bytes.decodeToString(endIndex = n).replace("\uFFFD", "")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML-decode the string
|
||||
*/
|
||||
fun String.htmlDecode(): String {
|
||||
return this.parseAsHtml().toString()
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
package ani.dantotsu.aniyomi.util.network
|
||||
|
||||
import android.webkit.CookieManager
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
class AndroidCookieJar : CookieJar {
|
||||
|
||||
private val manager = CookieManager.getInstance()
|
||||
|
||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
|
||||
val urlString = url.toString()
|
||||
|
||||
cookies.forEach { manager.setCookie(urlString, it.toString()) }
|
||||
}
|
||||
|
||||
override fun loadForRequest(url: HttpUrl): List<Cookie> {
|
||||
return get(url)
|
||||
}
|
||||
|
||||
fun get(url: HttpUrl): List<Cookie> {
|
||||
val cookies = manager.getCookie(url.toString())
|
||||
|
||||
return if (cookies != null && cookies.isNotEmpty()) {
|
||||
cookies.split(";").mapNotNull { Cookie.parse(url, it) }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(url: HttpUrl, cookieNames: List<String>? = null, maxAge: Int = -1): Int {
|
||||
val urlString = url.toString()
|
||||
val cookies = manager.getCookie(urlString) ?: return 0
|
||||
|
||||
fun List<String>.filterNames(): List<String> {
|
||||
return if (cookieNames != null) {
|
||||
this.filter { it in cookieNames }
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
return cookies.split(";")
|
||||
.map { it.substringBefore("=") }
|
||||
.filterNames()
|
||||
.onEach { manager.setCookie(urlString, "$it=;Max-Age=$maxAge") }
|
||||
.count()
|
||||
}
|
||||
|
||||
fun removeAll() {
|
||||
manager.removeAllCookies {}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,186 @@
|
|||
package ani.dantotsu.aniyomi.util.network
|
||||
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.dnsoverhttps.DnsOverHttps
|
||||
import java.net.InetAddress
|
||||
|
||||
/**
|
||||
* Based on https://github.com/square/okhttp/blob/ef5d0c83f7bbd3a0c0534e7ca23cbc4ee7550f3b/okhttp-dnsoverhttps/src/test/java/okhttp3/dnsoverhttps/DohProviders.java
|
||||
*/
|
||||
|
||||
const val PREF_DOH_CLOUDFLARE = 1
|
||||
const val PREF_DOH_GOOGLE = 2
|
||||
const val PREF_DOH_ADGUARD = 3
|
||||
const val PREF_DOH_QUAD9 = 4
|
||||
const val PREF_DOH_ALIDNS = 5
|
||||
const val PREF_DOH_DNSPOD = 6
|
||||
const val PREF_DOH_360 = 7
|
||||
const val PREF_DOH_QUAD101 = 8
|
||||
const val PREF_DOH_MULLVAD = 9
|
||||
const val PREF_DOH_CONTROLD = 10
|
||||
const val PREF_DOH_NJALLA = 11
|
||||
const val PREF_DOH_SHECAN = 12
|
||||
|
||||
fun OkHttpClient.Builder.dohCloudflare() = dns(
|
||||
DnsOverHttps.Builder().client(build())
|
||||
.url("https://cloudflare-dns.com/dns-query".toHttpUrl())
|
||||
.bootstrapDnsHosts(
|
||||
InetAddress.getByName("162.159.36.1"),
|
||||
InetAddress.getByName("162.159.46.1"),
|
||||
InetAddress.getByName("1.1.1.1"),
|
||||
InetAddress.getByName("1.0.0.1"),
|
||||
InetAddress.getByName("162.159.132.53"),
|
||||
InetAddress.getByName("2606:4700:4700::1111"),
|
||||
InetAddress.getByName("2606:4700:4700::1001"),
|
||||
InetAddress.getByName("2606:4700:4700::0064"),
|
||||
InetAddress.getByName("2606:4700:4700::6400"),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
|
||||
fun OkHttpClient.Builder.dohGoogle() = dns(
|
||||
DnsOverHttps.Builder().client(build())
|
||||
.url("https://dns.google/dns-query".toHttpUrl())
|
||||
.bootstrapDnsHosts(
|
||||
InetAddress.getByName("8.8.4.4"),
|
||||
InetAddress.getByName("8.8.8.8"),
|
||||
InetAddress.getByName("2001:4860:4860::8888"),
|
||||
InetAddress.getByName("2001:4860:4860::8844"),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
|
||||
// AdGuard "Default" DNS works too but for the sake of making sure no site is blacklisted,
|
||||
// we use "Unfiltered"
|
||||
fun OkHttpClient.Builder.dohAdGuard() = dns(
|
||||
DnsOverHttps.Builder().client(build())
|
||||
.url("https://dns-unfiltered.adguard.com/dns-query".toHttpUrl())
|
||||
.bootstrapDnsHosts(
|
||||
InetAddress.getByName("94.140.14.140"),
|
||||
InetAddress.getByName("94.140.14.141"),
|
||||
InetAddress.getByName("2a10:50c0::1:ff"),
|
||||
InetAddress.getByName("2a10:50c0::2:ff"),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
|
||||
fun OkHttpClient.Builder.dohQuad9() = dns(
|
||||
DnsOverHttps.Builder().client(build())
|
||||
.url("https://dns.quad9.net/dns-query".toHttpUrl())
|
||||
.bootstrapDnsHosts(
|
||||
InetAddress.getByName("9.9.9.9"),
|
||||
InetAddress.getByName("149.112.112.112"),
|
||||
InetAddress.getByName("2620:fe::fe"),
|
||||
InetAddress.getByName("2620:fe::9"),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
|
||||
fun OkHttpClient.Builder.dohAliDNS() = dns(
|
||||
DnsOverHttps.Builder().client(build())
|
||||
.url("https://dns.alidns.com/dns-query".toHttpUrl())
|
||||
.bootstrapDnsHosts(
|
||||
InetAddress.getByName("223.5.5.5"),
|
||||
InetAddress.getByName("223.6.6.6"),
|
||||
InetAddress.getByName("2400:3200::1"),
|
||||
InetAddress.getByName("2400:3200:baba::1"),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
|
||||
fun OkHttpClient.Builder.dohDNSPod() = dns(
|
||||
DnsOverHttps.Builder().client(build())
|
||||
.url("https://doh.pub/dns-query".toHttpUrl())
|
||||
.bootstrapDnsHosts(
|
||||
InetAddress.getByName("1.12.12.12"),
|
||||
InetAddress.getByName("120.53.53.53"),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
|
||||
fun OkHttpClient.Builder.doh360() = dns(
|
||||
DnsOverHttps.Builder().client(build())
|
||||
.url("https://doh.360.cn/dns-query".toHttpUrl())
|
||||
.bootstrapDnsHosts(
|
||||
InetAddress.getByName("101.226.4.6"),
|
||||
InetAddress.getByName("218.30.118.6"),
|
||||
InetAddress.getByName("123.125.81.6"),
|
||||
InetAddress.getByName("140.207.198.6"),
|
||||
InetAddress.getByName("180.163.249.75"),
|
||||
InetAddress.getByName("101.199.113.208"),
|
||||
InetAddress.getByName("36.99.170.86"),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
|
||||
fun OkHttpClient.Builder.dohQuad101() = dns(
|
||||
DnsOverHttps.Builder().client(build())
|
||||
.url("https://dns.twnic.tw/dns-query".toHttpUrl())
|
||||
.bootstrapDnsHosts(
|
||||
InetAddress.getByName("101.101.101.101"),
|
||||
InetAddress.getByName("2001:de4::101"),
|
||||
InetAddress.getByName("2001:de4::102"),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
|
||||
/*
|
||||
* Mullvad DoH
|
||||
* without ad blocking option
|
||||
* Source : https://mullvad.net/en/help/dns-over-https-and-dns-over-tls/
|
||||
*/
|
||||
fun OkHttpClient.Builder.dohMullvad() = dns(
|
||||
DnsOverHttps.Builder().client(build())
|
||||
.url("https://doh.mullvad.net/dns-query".toHttpUrl())
|
||||
.bootstrapDnsHosts(
|
||||
InetAddress.getByName("194.242.2.2"),
|
||||
InetAddress.getByName("193.19.108.2"),
|
||||
InetAddress.getByName("2a07:e340::2"),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
|
||||
/*
|
||||
* Control D
|
||||
* unfiltered option
|
||||
* Source : https://controld.com/free-dns/?
|
||||
*/
|
||||
fun OkHttpClient.Builder.dohControlD() = dns(
|
||||
DnsOverHttps.Builder().client(build())
|
||||
.url("https://freedns.controld.com/p0".toHttpUrl())
|
||||
.bootstrapDnsHosts(
|
||||
InetAddress.getByName("76.76.2.0"),
|
||||
InetAddress.getByName("76.76.10.0"),
|
||||
InetAddress.getByName("2606:1a40::"),
|
||||
InetAddress.getByName("2606:1a40:1::"),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
|
||||
/*
|
||||
* Njalla
|
||||
* Non logging and uncensored
|
||||
*/
|
||||
fun OkHttpClient.Builder.dohNajalla() = dns(
|
||||
DnsOverHttps.Builder().client(build())
|
||||
.url("https://dns.njal.la/dns-query".toHttpUrl())
|
||||
.bootstrapDnsHosts(
|
||||
InetAddress.getByName("95.215.19.53"),
|
||||
InetAddress.getByName("2001:67c:2354:2::53"),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Source: https://shecan.ir/
|
||||
*/
|
||||
fun OkHttpClient.Builder.dohShecan() = dns(
|
||||
DnsOverHttps.Builder().client(build())
|
||||
.url("https://free.shecan.ir/dns-query".toHttpUrl())
|
||||
.bootstrapDnsHosts(
|
||||
InetAddress.getByName("178.22.122.100"),
|
||||
InetAddress.getByName("185.51.200.2"),
|
||||
)
|
||||
.build(),
|
||||
)
|
|
@ -0,0 +1,79 @@
|
|||
package eu.kanade.tachiyomi.network
|
||||
|
||||
import android.content.Context
|
||||
import ani.dantotsu.aniyomi.util.network.AndroidCookieJar
|
||||
import ani.dantotsu.aniyomi.util.network.PREF_DOH_CLOUDFLARE
|
||||
import ani.dantotsu.aniyomi.util.network.PREF_DOH_GOOGLE
|
||||
import ani.dantotsu.aniyomi.util.network.dohCloudflare
|
||||
import ani.dantotsu.aniyomi.util.network.dohGoogle
|
||||
import ani.dantotsu.aniyomi.util.network.interceptor.CloudflareInterceptor
|
||||
import eu.kanade.tachiyomi.network.interceptor.UncaughtExceptionInterceptor
|
||||
import eu.kanade.tachiyomi.network.interceptor.UserAgentInterceptor
|
||||
import okhttp3.Cache
|
||||
import okhttp3.OkHttpClient
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class NetworkHelper(
|
||||
context: Context,
|
||||
) {
|
||||
|
||||
private val cacheDir = File(context.cacheDir, "network_cache")
|
||||
private val cacheSize = 5L * 1024 * 1024 // 5 MiB
|
||||
|
||||
val cookieJar = AndroidCookieJar()
|
||||
|
||||
private val userAgentInterceptor by lazy {
|
||||
UserAgentInterceptor(::defaultUserAgentProvider)
|
||||
}
|
||||
private val cloudflareInterceptor by lazy {
|
||||
CloudflareInterceptor(context, cookieJar, ::defaultUserAgentProvider)
|
||||
}
|
||||
|
||||
private val baseClientBuilder: OkHttpClient.Builder
|
||||
get() {
|
||||
val builder = OkHttpClient.Builder()
|
||||
.cookieJar(cookieJar)
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.callTimeout(2, TimeUnit.MINUTES)
|
||||
.addInterceptor(UncaughtExceptionInterceptor())
|
||||
.addInterceptor(userAgentInterceptor)
|
||||
|
||||
/*if (preferences.verboseLogging().get()) {
|
||||
val httpLoggingInterceptor = HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.HEADERS
|
||||
}
|
||||
builder.addNetworkInterceptor(httpLoggingInterceptor)
|
||||
}*/
|
||||
|
||||
//when (preferences.dohProvider().get()) {
|
||||
when (PREF_DOH_CLOUDFLARE) {
|
||||
PREF_DOH_CLOUDFLARE -> builder.dohCloudflare()
|
||||
PREF_DOH_GOOGLE -> builder.dohGoogle()
|
||||
/*PREF_DOH_ADGUARD -> builder.dohAdGuard()
|
||||
PREF_DOH_QUAD9 -> builder.dohQuad9()
|
||||
PREF_DOH_ALIDNS -> builder.dohAliDNS()
|
||||
PREF_DOH_DNSPOD -> builder.dohDNSPod()
|
||||
PREF_DOH_360 -> builder.doh360()
|
||||
PREF_DOH_QUAD101 -> builder.dohQuad101()
|
||||
PREF_DOH_MULLVAD -> builder.dohMullvad()
|
||||
PREF_DOH_CONTROLD -> builder.dohControlD()
|
||||
PREF_DOH_NJALLA -> builder.dohNajalla()
|
||||
PREF_DOH_SHECAN -> builder.dohShecan()*/
|
||||
}
|
||||
|
||||
return builder
|
||||
}
|
||||
|
||||
val client by lazy { baseClientBuilder.cache(Cache(cacheDir, cacheSize)).build() }
|
||||
|
||||
@Suppress("UNUSED")
|
||||
val cloudflareClient by lazy {
|
||||
client.newBuilder()
|
||||
.addInterceptor(cloudflareInterceptor)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun defaultUserAgentProvider() = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:110.0) Gecko/20100101 Firefox/110.0"//preferences.defaultUserAgent().get().trim()
|
||||
}
|
|
@ -0,0 +1,150 @@
|
|||
package eu.kanade.tachiyomi.network
|
||||
|
||||
import ani.dantotsu.aniyomi.util.network.ProgressListener
|
||||
import ani.dantotsu.aniyomi.util.network.ProgressResponseBody
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.serialization.DeserializationStrategy
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.okio.decodeFromBufferedSource
|
||||
import kotlinx.serialization.serializer
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import rx.Observable
|
||||
import rx.Producer
|
||||
import rx.Subscription
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
val jsonMime = "application/json; charset=utf-8".toMediaType()
|
||||
|
||||
fun Call.asObservable(): Observable<Response> {
|
||||
return Observable.unsafeCreate { subscriber ->
|
||||
// Since Call is a one-shot type, clone it for each new subscriber.
|
||||
val call = clone()
|
||||
|
||||
// Wrap the call in a helper which handles both unsubscription and backpressure.
|
||||
val requestArbiter = object : AtomicBoolean(), Producer, Subscription {
|
||||
override fun request(n: Long) {
|
||||
if (n == 0L || !compareAndSet(false, true)) return
|
||||
|
||||
try {
|
||||
val response = call.execute()
|
||||
if (!subscriber.isUnsubscribed) {
|
||||
subscriber.onNext(response)
|
||||
subscriber.onCompleted()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (!subscriber.isUnsubscribed) {
|
||||
subscriber.onError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun unsubscribe() {
|
||||
call.cancel()
|
||||
}
|
||||
|
||||
override fun isUnsubscribed(): Boolean {
|
||||
return call.isCanceled()
|
||||
}
|
||||
}
|
||||
|
||||
subscriber.add(requestArbiter)
|
||||
subscriber.setProducer(requestArbiter)
|
||||
}
|
||||
}
|
||||
|
||||
// Based on https://github.com/gildor/kotlin-coroutines-okhttp
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private suspend fun Call.await(callStack: Array<StackTraceElement>): Response {
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
val callback =
|
||||
object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
continuation.resume(response) {
|
||||
response.body.close()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
// Don't bother with resuming the continuation if it is already cancelled.
|
||||
if (continuation.isCancelled) return
|
||||
val exception = IOException(e.message, e).apply { stackTrace = callStack }
|
||||
continuation.resumeWithException(exception)
|
||||
}
|
||||
}
|
||||
|
||||
enqueue(callback)
|
||||
|
||||
continuation.invokeOnCancellation {
|
||||
try {
|
||||
cancel()
|
||||
} catch (ex: Throwable) {
|
||||
// Ignore cancel exception
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun Call.await(): Response {
|
||||
val callStack = Exception().stackTrace.run { copyOfRange(1, size) }
|
||||
return await(callStack)
|
||||
}
|
||||
|
||||
suspend fun Call.awaitSuccess(): Response {
|
||||
val callStack = Exception().stackTrace.run { copyOfRange(1, size) }
|
||||
val response = await(callStack)
|
||||
if (!response.isSuccessful) {
|
||||
response.close()
|
||||
throw HttpException(response.code).apply { stackTrace = callStack }
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
fun Call.asObservableSuccess(): Observable<Response> {
|
||||
return asObservable().doOnNext { response ->
|
||||
if (!response.isSuccessful) {
|
||||
response.close()
|
||||
throw HttpException(response.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun OkHttpClient.newCachelessCallWithProgress(request: Request, listener: ProgressListener): Call {
|
||||
val progressClient = newBuilder()
|
||||
.cache(null)
|
||||
.addNetworkInterceptor { chain ->
|
||||
val originalResponse = chain.proceed(chain.request())
|
||||
originalResponse.newBuilder()
|
||||
.body(ProgressResponseBody(originalResponse.body, listener))
|
||||
.build()
|
||||
}
|
||||
.build()
|
||||
|
||||
return progressClient.newCall(request)
|
||||
}
|
||||
|
||||
context(Json)
|
||||
inline fun <reified T> Response.parseAs(): T {
|
||||
return decodeFromJsonResponse(serializer(), this)
|
||||
}
|
||||
|
||||
context(Json)
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
fun <T> decodeFromJsonResponse(
|
||||
deserializer: DeserializationStrategy<T>,
|
||||
response: Response,
|
||||
): T {
|
||||
return response.body.source().use {
|
||||
decodeFromBufferedSource(deserializer, it)
|
||||
}
|
||||
}
|
||||
|
||||
class HttpException(val code: Int) : IllegalStateException("HTTP error $code")
|
|
@ -0,0 +1,5 @@
|
|||
package ani.dantotsu.aniyomi.util.network
|
||||
|
||||
interface ProgressListener {
|
||||
fun update(bytesRead: Long, contentLength: Long, done: Boolean)
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
package ani.dantotsu.aniyomi.util.network
|
||||
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.ResponseBody
|
||||
import okio.Buffer
|
||||
import okio.BufferedSource
|
||||
import okio.ForwardingSource
|
||||
import okio.Source
|
||||
import okio.buffer
|
||||
import java.io.IOException
|
||||
|
||||
class ProgressResponseBody(private val responseBody: ResponseBody, private val progressListener: ProgressListener) : ResponseBody() {
|
||||
|
||||
private val bufferedSource: BufferedSource by lazy {
|
||||
source(responseBody.source()).buffer()
|
||||
}
|
||||
|
||||
override fun contentType(): MediaType? {
|
||||
return responseBody.contentType()
|
||||
}
|
||||
|
||||
override fun contentLength(): Long {
|
||||
return responseBody.contentLength()
|
||||
}
|
||||
|
||||
override fun source(): BufferedSource {
|
||||
return bufferedSource
|
||||
}
|
||||
|
||||
private fun source(source: Source): Source {
|
||||
return object : ForwardingSource(source) {
|
||||
var totalBytesRead = 0L
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun read(sink: Buffer, byteCount: Long): Long {
|
||||
val bytesRead = super.read(sink, byteCount)
|
||||
// read() returns the number of bytes read, or -1 if this source is exhausted.
|
||||
totalBytesRead += if (bytesRead != -1L) bytesRead else 0
|
||||
progressListener.update(totalBytesRead, responseBody.contentLength(), bytesRead == -1L)
|
||||
return bytesRead
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,79 @@
|
|||
package eu.kanade.tachiyomi.network
|
||||
|
||||
import okhttp3.CacheControl
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import java.util.concurrent.TimeUnit.MINUTES
|
||||
|
||||
private val DEFAULT_CACHE_CONTROL = CacheControl.Builder().maxAge(10, MINUTES).build()
|
||||
private val DEFAULT_HEADERS = Headers.Builder().build()
|
||||
private val DEFAULT_BODY: RequestBody = FormBody.Builder().build()
|
||||
|
||||
fun GET(
|
||||
url: String,
|
||||
headers: Headers = DEFAULT_HEADERS,
|
||||
cache: CacheControl = DEFAULT_CACHE_CONTROL,
|
||||
): Request {
|
||||
return GET(url.toHttpUrl(), headers, cache)
|
||||
}
|
||||
|
||||
/**
|
||||
* @since extensions-lib 1.4
|
||||
*/
|
||||
fun GET(
|
||||
url: HttpUrl,
|
||||
headers: Headers = DEFAULT_HEADERS,
|
||||
cache: CacheControl = DEFAULT_CACHE_CONTROL,
|
||||
): Request {
|
||||
return Request.Builder()
|
||||
.url(url)
|
||||
.headers(headers)
|
||||
.cacheControl(cache)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun POST(
|
||||
url: String,
|
||||
headers: Headers = DEFAULT_HEADERS,
|
||||
body: RequestBody = DEFAULT_BODY,
|
||||
cache: CacheControl = DEFAULT_CACHE_CONTROL,
|
||||
): Request {
|
||||
return Request.Builder()
|
||||
.url(url)
|
||||
.post(body)
|
||||
.headers(headers)
|
||||
.cacheControl(cache)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun PUT(
|
||||
url: String,
|
||||
headers: Headers = DEFAULT_HEADERS,
|
||||
body: RequestBody = DEFAULT_BODY,
|
||||
cache: CacheControl = DEFAULT_CACHE_CONTROL,
|
||||
): Request {
|
||||
return Request.Builder()
|
||||
.url(url)
|
||||
.put(body)
|
||||
.headers(headers)
|
||||
.cacheControl(cache)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun DELETE(
|
||||
url: String,
|
||||
headers: Headers = DEFAULT_HEADERS,
|
||||
body: RequestBody = DEFAULT_BODY,
|
||||
cache: CacheControl = DEFAULT_CACHE_CONTROL,
|
||||
): Request {
|
||||
return Request.Builder()
|
||||
.url(url)
|
||||
.delete(body)
|
||||
.headers(headers)
|
||||
.cacheControl(cache)
|
||||
.build()
|
||||
}
|
|
@ -0,0 +1,140 @@
|
|||
package ani.dantotsu.aniyomi.util.network.interceptor
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.webkit.WebView
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.ContextCompat
|
||||
import ani.dantotsu.aniyomi.util.network.AndroidCookieJar
|
||||
import ani.dantotsu.aniyomi.util.system.WebViewClientCompat
|
||||
import ani.dantotsu.aniyomi.util.system.isOutdated
|
||||
import ani.dantotsu.aniyomi.util.toast
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.CountDownLatch
|
||||
|
||||
class CloudflareInterceptor(
|
||||
private val context: Context,
|
||||
private val cookieManager: AndroidCookieJar,
|
||||
defaultUserAgentProvider: () -> String,
|
||||
) : WebViewInterceptor(context, defaultUserAgentProvider) {
|
||||
|
||||
private val executor = ContextCompat.getMainExecutor(context)
|
||||
|
||||
override fun shouldIntercept(response: Response): Boolean {
|
||||
// Check if Cloudflare anti-bot is on
|
||||
return response.code in ERROR_CODES && response.header("Server") in SERVER_CHECK
|
||||
}
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain, request: Request, response: Response): Response {
|
||||
try {
|
||||
response.close()
|
||||
cookieManager.remove(request.url, COOKIE_NAMES, 0)
|
||||
val oldCookie = cookieManager.get(request.url)
|
||||
.firstOrNull { it.name == "cf_clearance" }
|
||||
resolveWithWebView(request, oldCookie)
|
||||
|
||||
return chain.proceed(request)
|
||||
}
|
||||
// Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that
|
||||
// we don't crash the entire app
|
||||
catch (e: CloudflareBypassException) {
|
||||
throw IOException("Failed to bypass Cloudflare")
|
||||
} catch (e: Exception) {
|
||||
throw IOException(e)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
private fun resolveWithWebView(originalRequest: Request, oldCookie: Cookie?) {
|
||||
// We need to lock this thread until the WebView finds the challenge solution url, because
|
||||
// OkHttp doesn't support asynchronous interceptors.
|
||||
val latch = CountDownLatch(1)
|
||||
|
||||
var webview: WebView? = null
|
||||
|
||||
var challengeFound = false
|
||||
var cloudflareBypassed = false
|
||||
var isWebViewOutdated = false
|
||||
|
||||
val origRequestUrl = originalRequest.url.toString()
|
||||
val headers = parseHeaders(originalRequest.headers)
|
||||
|
||||
executor.execute {
|
||||
webview = createWebView(originalRequest)
|
||||
|
||||
webview?.webViewClient = object : WebViewClientCompat() {
|
||||
override fun onPageFinished(view: WebView, url: String) {
|
||||
fun isCloudFlareBypassed(): Boolean {
|
||||
return cookieManager.get(origRequestUrl.toHttpUrl())
|
||||
.firstOrNull { it.name == "cf_clearance" }
|
||||
.let { it != null && it != oldCookie }
|
||||
}
|
||||
|
||||
if (isCloudFlareBypassed()) {
|
||||
cloudflareBypassed = true
|
||||
latch.countDown()
|
||||
}
|
||||
|
||||
if (url == origRequestUrl && !challengeFound) {
|
||||
// The first request didn't return the challenge, abort.
|
||||
latch.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReceivedErrorCompat(
|
||||
view: WebView,
|
||||
errorCode: Int,
|
||||
description: String?,
|
||||
failingUrl: String,
|
||||
isMainFrame: Boolean,
|
||||
) {
|
||||
if (isMainFrame) {
|
||||
if (errorCode in ERROR_CODES) {
|
||||
// Found the Cloudflare challenge page.
|
||||
challengeFound = true
|
||||
} else {
|
||||
// Unlock thread, the challenge wasn't found.
|
||||
latch.countDown()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
webview?.loadUrl(origRequestUrl, headers)
|
||||
}
|
||||
|
||||
latch.awaitFor30Seconds()
|
||||
|
||||
executor.execute {
|
||||
if (!cloudflareBypassed) {
|
||||
isWebViewOutdated = webview?.isOutdated() == true
|
||||
}
|
||||
|
||||
webview?.run {
|
||||
stopLoading()
|
||||
destroy()
|
||||
}
|
||||
}
|
||||
|
||||
// Throw exception if we failed to bypass Cloudflare
|
||||
if (!cloudflareBypassed) {
|
||||
// Prompt user to update WebView if it seems too outdated
|
||||
if (isWebViewOutdated) {
|
||||
context.toast("Please update the webview app for better compatibility", Toast.LENGTH_LONG)
|
||||
}
|
||||
|
||||
throw CloudflareBypassException()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val ERROR_CODES = listOf(403, 503)
|
||||
private val SERVER_CHECK = arrayOf("cloudflare-nginx", "cloudflare")
|
||||
private val COOKIE_NAMES = listOf("cf_clearance")
|
||||
|
||||
private class CloudflareBypassException : Exception()
|
|
@ -0,0 +1,28 @@
|
|||
package eu.kanade.tachiyomi.network.interceptor
|
||||
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Catches any uncaught exceptions from later in the chain and rethrows as a non-fatal
|
||||
* IOException to avoid catastrophic failure.
|
||||
*
|
||||
* This should be the first interceptor in the client.
|
||||
*
|
||||
* See https://square.github.io/okhttp/4.x/okhttp/okhttp3/-interceptor/
|
||||
*/
|
||||
class UncaughtExceptionInterceptor : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
return try {
|
||||
chain.proceed(chain.request())
|
||||
} catch (e: Exception) {
|
||||
if (e is IOException) {
|
||||
throw e
|
||||
} else {
|
||||
throw IOException(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
package eu.kanade.tachiyomi.network.interceptor
|
||||
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
class UserAgentInterceptor(
|
||||
private val defaultUserAgentProvider: () -> String,
|
||||
) : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest = chain.request()
|
||||
|
||||
return if (originalRequest.header("User-Agent").isNullOrEmpty()) {
|
||||
val newRequest = originalRequest
|
||||
.newBuilder()
|
||||
.removeHeader("User-Agent")
|
||||
.addHeader("User-Agent", defaultUserAgentProvider())
|
||||
.build()
|
||||
chain.proceed(newRequest)
|
||||
} else {
|
||||
chain.proceed(originalRequest)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,101 @@
|
|||
package ani.dantotsu.aniyomi.util.network.interceptor
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.widget.Toast
|
||||
import ani.dantotsu.aniyomi.util.system.DeviceUtil
|
||||
import ani.dantotsu.aniyomi.util.system.WebViewUtil
|
||||
import ani.dantotsu.aniyomi.util.system.setDefaultSettings
|
||||
import ani.dantotsu.aniyomi.util.toast
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import okhttp3.Headers
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import ani.dantotsu.aniyomi.util.launchUI
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
abstract class WebViewInterceptor(
|
||||
private val context: Context,
|
||||
private val defaultUserAgentProvider: () -> String,
|
||||
) : Interceptor {
|
||||
|
||||
/**
|
||||
* When this is called, it initializes the WebView if it wasn't already. We use this to avoid
|
||||
* blocking the main thread too much. If used too often we could consider moving it to the
|
||||
* Application class.
|
||||
*/
|
||||
private val initWebView by lazy {
|
||||
// Crashes on some devices. We skip this in some cases since the only impact is slower
|
||||
// WebView init in those rare cases.
|
||||
// See https://bugs.chromium.org/p/chromium/issues/detail?id=1279562
|
||||
if (DeviceUtil.isMiui || Build.VERSION.SDK_INT == Build.VERSION_CODES.S && DeviceUtil.isSamsung) {
|
||||
return@lazy
|
||||
}
|
||||
|
||||
try {
|
||||
WebSettings.getDefaultUserAgent(context)
|
||||
} catch (_: Exception) {
|
||||
// Avoid some crashes like when Chrome/WebView is being updated.
|
||||
}
|
||||
}
|
||||
|
||||
abstract fun shouldIntercept(response: Response): Boolean
|
||||
|
||||
abstract fun intercept(chain: Interceptor.Chain, request: Request, response: Response): Response
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val response = chain.proceed(request)
|
||||
if (!shouldIntercept(response)) {
|
||||
return response
|
||||
}
|
||||
|
||||
if (!WebViewUtil.supportsWebView(context)) {
|
||||
launchUI {
|
||||
context.toast("Webview is required for dantotsu", Toast.LENGTH_LONG)
|
||||
}
|
||||
return response
|
||||
}
|
||||
initWebView
|
||||
|
||||
return intercept(chain, request, response)
|
||||
}
|
||||
|
||||
fun parseHeaders(headers: Headers): Map<String, String> {
|
||||
return headers
|
||||
// Keeping unsafe header makes webview throw [net::ERR_INVALID_ARGUMENT]
|
||||
.filter { (name, value) ->
|
||||
isRequestHeaderSafe(name, value)
|
||||
}
|
||||
.groupBy(keySelector = { (name, _) -> name }) { (_, value) -> value }
|
||||
.mapValues { it.value.getOrNull(0).orEmpty() }
|
||||
}
|
||||
|
||||
fun CountDownLatch.awaitFor30Seconds() {
|
||||
await(30, TimeUnit.SECONDS)
|
||||
}
|
||||
|
||||
fun createWebView(request: Request): WebView {
|
||||
return WebView(context).apply {
|
||||
setDefaultSettings()
|
||||
// Avoid sending empty User-Agent, Chromium WebView will reset to default if empty
|
||||
settings.userAgentString = request.header("User-Agent") ?: defaultUserAgentProvider()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Based on [IsRequestHeaderSafe] in https://source.chromium.org/chromium/chromium/src/+/main:services/network/public/cpp/header_util.cc
|
||||
private fun isRequestHeaderSafe(_name: String, _value: String): Boolean {
|
||||
val name = _name.lowercase(Locale.ENGLISH)
|
||||
val value = _value.lowercase(Locale.ENGLISH)
|
||||
if (name in unsafeHeaderNames || name.startsWith("proxy-")) return false
|
||||
if (name == "connection" && value == "upgrade") return false
|
||||
return true
|
||||
}
|
||||
private val unsafeHeaderNames = listOf("content-length", "host", "trailer", "te", "upgrade", "cookie2", "keep-alive", "transfer-encoding", "set-cookie")
|
|
@ -0,0 +1,3 @@
|
|||
package ani.dantotsu.aniyomi.util.srcapi
|
||||
|
||||
//actual suspend fun <T> Observable<T>.awaitSingle(): T = awaitSingle()
|
|
@ -0,0 +1,24 @@
|
|||
package ani.dantotsu.aniyomi.util.storage
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.net.toUri
|
||||
import java.io.File
|
||||
|
||||
val Context.cacheImageDir: File
|
||||
get() = File(cacheDir, "shared_image")
|
||||
|
||||
/**
|
||||
* Returns the uri of a file
|
||||
*
|
||||
* @param context context of application
|
||||
*/
|
||||
fun File.getUriCompat(context: Context): Uri {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
FileProvider.getUriForFile(context, context.packageName + ".provider", this)
|
||||
} else {
|
||||
this.toUri()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,194 @@
|
|||
package ani.dantotsu.aniyomi.util.system
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.util.TypedValue
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.core.content.PermissionChecker
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.graphics.alpha
|
||||
import androidx.core.graphics.blue
|
||||
import androidx.core.graphics.green
|
||||
import androidx.core.graphics.red
|
||||
import androidx.core.net.toUri
|
||||
import ani.dantotsu.aniyomi.util.lang.truncateCenter
|
||||
import logcat.LogPriority
|
||||
import ani.dantotsu.aniyomi.util.logcat
|
||||
import ani.dantotsu.toast
|
||||
import com.hippo.unifile.UniFile
|
||||
import java.io.File
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Copies a string to clipboard
|
||||
*
|
||||
* @param label Label to show to the user describing the content
|
||||
* @param content the actual text to copy to the board
|
||||
*/
|
||||
fun Context.copyToClipboard(label: String, content: String) {
|
||||
if (content.isBlank()) return
|
||||
|
||||
try {
|
||||
val clipboard = getSystemService<ClipboardManager>()!!
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText(label, content))
|
||||
|
||||
// Android 13 and higher shows a visual confirmation of copied contents
|
||||
// https://developer.android.com/about/versions/13/features/copy-paste
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
|
||||
toast("Copied to clipboard: " + content.truncateCenter(50))
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
logcat(LogPriority.ERROR, e)
|
||||
toast("Failed to copy to clipboard")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the give permission is granted.
|
||||
*
|
||||
* @param permission the permission to check.
|
||||
* @return true if it has permissions.
|
||||
*/
|
||||
fun Context.hasPermission(permission: String) = PermissionChecker.checkSelfPermission(this, permission) == PermissionChecker.PERMISSION_GRANTED
|
||||
|
||||
/**
|
||||
* Returns the color for the given attribute.
|
||||
*
|
||||
* @param resource the attribute.
|
||||
* @param alphaFactor the alpha number [0,1].
|
||||
*/
|
||||
@ColorInt fun Context.getResourceColor(@AttrRes resource: Int, alphaFactor: Float = 1f): Int {
|
||||
val typedArray = obtainStyledAttributes(intArrayOf(resource))
|
||||
val color = typedArray.getColor(0, 0)
|
||||
typedArray.recycle()
|
||||
|
||||
if (alphaFactor < 1f) {
|
||||
val alpha = (color.alpha * alphaFactor).roundToInt()
|
||||
return Color.argb(alpha, color.red, color.green, color.blue)
|
||||
}
|
||||
|
||||
return color
|
||||
}
|
||||
|
||||
@ColorInt fun Context.getThemeColor(attr: Int): Int {
|
||||
val tv = TypedValue()
|
||||
return if (this.theme.resolveAttribute(attr, tv, true)) {
|
||||
if (tv.resourceId != 0) {
|
||||
getColor(tv.resourceId)
|
||||
} else {
|
||||
tv.data
|
||||
}
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
val Context.powerManager: PowerManager
|
||||
get() = getSystemService()!!
|
||||
|
||||
/**
|
||||
* Convenience method to acquire a partial wake lock.
|
||||
*/
|
||||
fun Context.acquireWakeLock(tag: String): PowerManager.WakeLock {
|
||||
val wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$tag:WakeLock")
|
||||
wakeLock.acquire()
|
||||
return wakeLock
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given service class is running.
|
||||
*/
|
||||
fun Context.isServiceRunning(serviceClass: Class<*>): Boolean {
|
||||
val className = serviceClass.name
|
||||
val manager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||
@Suppress("DEPRECATION")
|
||||
return manager.getRunningServices(Integer.MAX_VALUE)
|
||||
.any { className == it.service.className }
|
||||
}
|
||||
|
||||
fun Context.openInBrowser(url: String, forceDefaultBrowser: Boolean = false) {
|
||||
this.openInBrowser(url.toUri(), forceDefaultBrowser)
|
||||
}
|
||||
|
||||
fun Context.openInBrowser(uri: Uri, forceDefaultBrowser: Boolean = false) {
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_VIEW, uri).apply {
|
||||
// Force default browser so that verified extensions don't re-open Tachiyomi
|
||||
if (forceDefaultBrowser) {
|
||||
defaultBrowserPackageName()?.let { setPackage(it) }
|
||||
}
|
||||
}
|
||||
startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
toast(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Context.defaultBrowserPackageName(): String? {
|
||||
val browserIntent = Intent(Intent.ACTION_VIEW, "http://".toUri())
|
||||
val resolveInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
packageManager.resolveActivity(browserIntent, PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_DEFAULT_ONLY.toLong()))
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
packageManager.resolveActivity(browserIntent, PackageManager.MATCH_DEFAULT_ONLY)
|
||||
}
|
||||
return resolveInfo
|
||||
?.activityInfo?.packageName
|
||||
?.takeUnless { it in DeviceUtil.invalidDefaultBrowsers }
|
||||
}
|
||||
|
||||
fun Context.createFileInCacheDir(name: String): File {
|
||||
val file = File(externalCacheDir, name)
|
||||
if (file.exists()) {
|
||||
file.delete()
|
||||
}
|
||||
file.createNewFile()
|
||||
return file
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns true if [packageName] is installed.
|
||||
*/
|
||||
fun Context.isPackageInstalled(packageName: String): Boolean {
|
||||
return try {
|
||||
packageManager.getApplicationInfo(packageName, 0)
|
||||
true
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets document size of provided [Uri]
|
||||
*
|
||||
* @return document size of [uri] or null if size can't be obtained
|
||||
*/
|
||||
fun Context.getUriSize(uri: Uri): Long? {
|
||||
return UniFile.fromUri(this, uri).length().takeIf { it >= 0 }
|
||||
}
|
||||
|
||||
|
||||
val Context.hasMiuiPackageInstaller get() = isPackageInstalled("com.miui.packageinstaller")
|
||||
|
||||
val Context.isShizukuInstalled get() = false
|
||||
|
||||
|
||||
|
||||
fun Context.getApplicationIcon(pkgName: String): Drawable? {
|
||||
return try {
|
||||
packageManager.getApplicationIcon(pkgName)
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
null
|
||||
}
|
||||
}
|
|
@ -0,0 +1,79 @@
|
|||
package ani.dantotsu.aniyomi.util.system
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Build
|
||||
import logcat.LogPriority
|
||||
import ani.dantotsu.aniyomi.util.logcat
|
||||
|
||||
object DeviceUtil {
|
||||
|
||||
val isMiui by lazy {
|
||||
getSystemProperty("ro.miui.ui.version.name")?.isNotEmpty() ?: false
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the MIUI major version code from a string like "V12.5.3.0.QFGMIXM".
|
||||
*
|
||||
* @return MIUI major version code (e.g., 13) or null if can't be parsed.
|
||||
*/
|
||||
val miuiMajorVersion by lazy {
|
||||
if (!isMiui) return@lazy null
|
||||
|
||||
Build.VERSION.INCREMENTAL
|
||||
.substringBefore('.')
|
||||
.trimStart('V')
|
||||
.toIntOrNull()
|
||||
}
|
||||
|
||||
@SuppressLint("PrivateApi")
|
||||
fun isMiuiOptimizationDisabled(): Boolean {
|
||||
val sysProp = getSystemProperty("persist.sys.miui_optimization")
|
||||
if (sysProp == "0" || sysProp == "false") {
|
||||
return true
|
||||
}
|
||||
|
||||
return try {
|
||||
Class.forName("android.miui.AppOpsUtils")
|
||||
.getDeclaredMethod("isXOptMode")
|
||||
.invoke(null) as Boolean
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
val isSamsung by lazy {
|
||||
Build.MANUFACTURER.equals("samsung", ignoreCase = true)
|
||||
}
|
||||
|
||||
val oneUiVersion by lazy {
|
||||
try {
|
||||
val semPlatformIntField = Build.VERSION::class.java.getDeclaredField("SEM_PLATFORM_INT")
|
||||
val version = semPlatformIntField.getInt(null) - 90000
|
||||
if (version < 0) {
|
||||
1.0
|
||||
} else {
|
||||
((version / 10000).toString() + "." + version % 10000 / 100).toDouble()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val invalidDefaultBrowsers = listOf(
|
||||
"android",
|
||||
"com.huawei.android.internal.app",
|
||||
"com.zui.resolver",
|
||||
)
|
||||
|
||||
@SuppressLint("PrivateApi")
|
||||
private fun getSystemProperty(key: String?): String? {
|
||||
return try {
|
||||
Class.forName("android.os.SystemProperties")
|
||||
.getDeclaredMethod("get", String::class.java)
|
||||
.invoke(null, key) as String
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.WARN, e) { "Unable to use SystemProperties.get()" }
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
package ani.dantotsu.aniyomi.util.system
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.core.content.IntentCompat
|
||||
import java.io.Serializable
|
||||
|
||||
fun Uri.toShareIntent(context: Context, type: String = "image/*", message: String? = null): Intent {
|
||||
val uri = this
|
||||
|
||||
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
||||
when (uri.scheme) {
|
||||
"http", "https" -> {
|
||||
putExtra(Intent.EXTRA_TEXT, uri.toString())
|
||||
}
|
||||
"content" -> {
|
||||
message?.let { putExtra(Intent.EXTRA_TEXT, it) }
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
}
|
||||
}
|
||||
clipData = ClipData.newRawUri(null, uri)
|
||||
setType(type)
|
||||
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
}
|
||||
|
||||
return Intent.createChooser(shareIntent, "Share").apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified T> Intent.getParcelableExtraCompat(name: String): T? {
|
||||
return IntentCompat.getParcelableExtra(this, name, T::class.java)
|
||||
}
|
||||
|
||||
inline fun <reified T : Serializable> Intent.getSerializableExtraCompat(name: String): T? {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
getSerializableExtra(name, T::class.java)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
getSerializableExtra(name) as? T
|
||||
}
|
||||
}
|
|
@ -0,0 +1,66 @@
|
|||
package ani.dantotsu.aniyomi.util.system
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Utility class to change the application's language in runtime.
|
||||
*/
|
||||
object LocaleHelper {
|
||||
|
||||
val comparator = compareBy<String>(
|
||||
{ getDisplayName(it) },
|
||||
{ it == "all" },
|
||||
)
|
||||
|
||||
/**
|
||||
* Returns display name of a string language code.
|
||||
*/
|
||||
fun getSourceDisplayName(lang: String?, context: Context): String {
|
||||
return when (lang) {
|
||||
LAST_USED_KEY -> "Last used"
|
||||
PINNED_KEY -> "Pinned"
|
||||
"other" -> "Other"
|
||||
"all" -> "Multi"
|
||||
else -> getDisplayName(lang)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns display name of a string language code.
|
||||
*
|
||||
* @param lang empty for system language
|
||||
*/
|
||||
fun getDisplayName(lang: String?): String {
|
||||
if (lang == null) {
|
||||
return ""
|
||||
}
|
||||
|
||||
val locale = when (lang) {
|
||||
"" -> LocaleListCompat.getAdjustedDefault()[0]
|
||||
"zh-CN" -> Locale.forLanguageTag("zh-Hans")
|
||||
"zh-TW" -> Locale.forLanguageTag("zh-Hant")
|
||||
else -> Locale.forLanguageTag(lang)
|
||||
}
|
||||
return locale!!.getDisplayName(locale).replaceFirstChar { it.uppercase(locale) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the default languages enabled for the sources.
|
||||
*/
|
||||
fun getDefaultEnabledLanguages(): Set<String> {
|
||||
return setOf("all", "en", Locale.getDefault().language)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return English display string from string language code
|
||||
*/
|
||||
fun getSimpleLocaleDisplayName(): String {
|
||||
val sp = Locale.getDefault().language.split("_", "-")
|
||||
return Locale(sp[0]).getDisplayLanguage(LocaleListCompat.getDefault()[0]!!)
|
||||
}
|
||||
}
|
||||
|
||||
internal const val PINNED_KEY = "pinned"
|
||||
internal const val LAST_USED_KEY = "last_used"
|
|
@ -0,0 +1,92 @@
|
|||
package ani.dantotsu.aniyomi.util.system
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Notification
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.core.app.NotificationChannelGroupCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.NotificationManagerCompat.NotificationWithIdAndTag
|
||||
import androidx.core.content.PermissionChecker
|
||||
import androidx.core.content.getSystemService
|
||||
|
||||
val Context.notificationManager: NotificationManager
|
||||
get() = getSystemService()!!
|
||||
|
||||
fun Context.notify(id: Int, channelId: String, block: (NotificationCompat.Builder.() -> Unit)? = null) {
|
||||
val notification = notificationBuilder(channelId, block).build()
|
||||
this.notify(id, notification)
|
||||
}
|
||||
|
||||
fun Context.notify(id: Int, notification: Notification) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && PermissionChecker.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PermissionChecker.PERMISSION_GRANTED) {
|
||||
return
|
||||
}
|
||||
|
||||
NotificationManagerCompat.from(this).notify(id, notification)
|
||||
}
|
||||
|
||||
fun Context.notify(notificationWithIdAndTags: List<NotificationWithIdAndTag>) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && PermissionChecker.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PermissionChecker.PERMISSION_GRANTED) {
|
||||
return
|
||||
}
|
||||
|
||||
NotificationManagerCompat.from(this).notify(notificationWithIdAndTags)
|
||||
}
|
||||
|
||||
fun Context.cancelNotification(id: Int) {
|
||||
NotificationManagerCompat.from(this).cancel(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to create a notification builder.
|
||||
*
|
||||
* @param id the channel id.
|
||||
* @param block the function that will execute inside the builder.
|
||||
* @return a notification to be displayed or updated.
|
||||
*/
|
||||
fun Context.notificationBuilder(channelId: String, block: (NotificationCompat.Builder.() -> Unit)? = null): NotificationCompat.Builder {
|
||||
val builder = NotificationCompat.Builder(this, channelId)
|
||||
.setColor(getColor(android.R.color.holo_blue_dark))
|
||||
if (block != null) {
|
||||
builder.block()
|
||||
}
|
||||
return builder
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to build a notification channel group.
|
||||
*
|
||||
* @param channelId the channel id.
|
||||
* @param block the function that will execute inside the builder.
|
||||
* @return a notification channel group to be displayed or updated.
|
||||
*/
|
||||
fun buildNotificationChannelGroup(
|
||||
channelId: String,
|
||||
block: (NotificationChannelGroupCompat.Builder.() -> Unit),
|
||||
): NotificationChannelGroupCompat {
|
||||
val builder = NotificationChannelGroupCompat.Builder(channelId)
|
||||
builder.block()
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to build a notification channel.
|
||||
*
|
||||
* @param channelId the channel id.
|
||||
* @param channelImportance the channel importance.
|
||||
* @param block the function that will execute inside the builder.
|
||||
* @return a notification channel to be displayed or updated.
|
||||
*/
|
||||
fun buildNotificationChannel(
|
||||
channelId: String,
|
||||
channelImportance: Int,
|
||||
block: (NotificationChannelCompat.Builder.() -> Unit),
|
||||
): NotificationChannelCompat {
|
||||
val builder = NotificationChannelCompat.Builder(channelId, channelImportance)
|
||||
builder.block()
|
||||
return builder.build()
|
||||
}
|
|
@ -0,0 +1,94 @@
|
|||
package ani.dantotsu.aniyomi.util.system
|
||||
|
||||
import android.annotation.TargetApi
|
||||
import android.os.Build
|
||||
import android.webkit.WebResourceError
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
|
||||
@Suppress("OverridingDeprecatedMember")
|
||||
abstract class WebViewClientCompat : WebViewClient() {
|
||||
|
||||
open fun shouldOverrideUrlCompat(view: WebView, url: String): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
open fun shouldInterceptRequestCompat(view: WebView, url: String): WebResourceResponse? {
|
||||
return null
|
||||
}
|
||||
|
||||
open fun onReceivedErrorCompat(
|
||||
view: WebView,
|
||||
errorCode: Int,
|
||||
description: String?,
|
||||
failingUrl: String,
|
||||
isMainFrame: Boolean,
|
||||
) {
|
||||
}
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.N)
|
||||
final override fun shouldOverrideUrlLoading(
|
||||
view: WebView,
|
||||
request: WebResourceRequest,
|
||||
): Boolean {
|
||||
return shouldOverrideUrlCompat(view, request.url.toString())
|
||||
}
|
||||
|
||||
final override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
|
||||
return shouldOverrideUrlCompat(view, url)
|
||||
}
|
||||
|
||||
final override fun shouldInterceptRequest(
|
||||
view: WebView,
|
||||
request: WebResourceRequest,
|
||||
): WebResourceResponse? {
|
||||
return shouldInterceptRequestCompat(view, request.url.toString())
|
||||
}
|
||||
|
||||
final override fun shouldInterceptRequest(
|
||||
view: WebView,
|
||||
url: String,
|
||||
): WebResourceResponse? {
|
||||
return shouldInterceptRequestCompat(view, url)
|
||||
}
|
||||
|
||||
final override fun onReceivedError(
|
||||
view: WebView,
|
||||
request: WebResourceRequest,
|
||||
error: WebResourceError,
|
||||
) {
|
||||
onReceivedErrorCompat(
|
||||
view,
|
||||
error.errorCode,
|
||||
error.description?.toString(),
|
||||
request.url.toString(),
|
||||
request.isForMainFrame,
|
||||
)
|
||||
}
|
||||
|
||||
final override fun onReceivedError(
|
||||
view: WebView,
|
||||
errorCode: Int,
|
||||
description: String?,
|
||||
failingUrl: String,
|
||||
) {
|
||||
onReceivedErrorCompat(view, errorCode, description, failingUrl, failingUrl == view.url)
|
||||
}
|
||||
|
||||
final override fun onReceivedHttpError(
|
||||
view: WebView,
|
||||
request: WebResourceRequest,
|
||||
error: WebResourceResponse,
|
||||
) {
|
||||
onReceivedErrorCompat(
|
||||
view,
|
||||
error.statusCode,
|
||||
error.reasonPhrase,
|
||||
request.url
|
||||
.toString(),
|
||||
request.isForMainFrame,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
package ani.dantotsu.aniyomi.util.system
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import logcat.LogPriority
|
||||
import ani.dantotsu.aniyomi.util.logcat
|
||||
|
||||
object WebViewUtil {
|
||||
const val SPOOF_PACKAGE_NAME = "org.chromium.chrome"
|
||||
|
||||
const val MINIMUM_WEBVIEW_VERSION = 108
|
||||
|
||||
fun supportsWebView(context: Context): Boolean {
|
||||
try {
|
||||
// May throw android.webkit.WebViewFactory$MissingWebViewPackageException if WebView
|
||||
// is not installed
|
||||
CookieManager.getInstance()
|
||||
} catch (e: Throwable) {
|
||||
logcat(LogPriority.ERROR, e)
|
||||
return false
|
||||
}
|
||||
|
||||
return context.packageManager.hasSystemFeature(PackageManager.FEATURE_WEBVIEW)
|
||||
}
|
||||
}
|
||||
|
||||
fun WebView.isOutdated(): Boolean {
|
||||
return getWebViewMajorVersion() < WebViewUtil.MINIMUM_WEBVIEW_VERSION
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
fun WebView.setDefaultSettings() {
|
||||
with(settings) {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
databaseEnabled = true
|
||||
useWideViewPort = true
|
||||
loadWithOverviewMode = true
|
||||
cacheMode = WebSettings.LOAD_DEFAULT
|
||||
|
||||
// Allow zooming
|
||||
setSupportZoom(true)
|
||||
builtInZoomControls = true
|
||||
displayZoomControls = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun WebView.getWebViewMajorVersion(): Int {
|
||||
val uaRegexMatch = """.*Chrome/(\d+)\..*""".toRegex().matchEntire(getDefaultUserAgentString())
|
||||
return if (uaRegexMatch != null && uaRegexMatch.groupValues.size > 1) {
|
||||
uaRegexMatch.groupValues[1].toInt()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
// Based on https://stackoverflow.com/a/29218966
|
||||
private fun WebView.getDefaultUserAgentString(): String {
|
||||
val originalUA: String = settings.userAgentString
|
||||
|
||||
// Next call to getUserAgentString() will get us the default
|
||||
settings.userAgentString = null
|
||||
val defaultUserAgentString = settings.userAgentString
|
||||
|
||||
// Revert to original UA string
|
||||
settings.userAgentString = originalUA
|
||||
|
||||
return defaultUserAgentString
|
||||
}
|
39
app/src/main/java/ani/dantotsu/connections/UpdateProgress.kt
Normal file
39
app/src/main/java/ani/dantotsu/connections/UpdateProgress.kt
Normal file
|
@ -0,0 +1,39 @@
|
|||
package ani.dantotsu.connections
|
||||
|
||||
import ani.dantotsu.R
|
||||
import ani.dantotsu.Refresh
|
||||
import ani.dantotsu.connections.anilist.Anilist
|
||||
import ani.dantotsu.connections.mal.MAL
|
||||
import ani.dantotsu.currContext
|
||||
import ani.dantotsu.media.Media
|
||||
import ani.dantotsu.toast
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
fun updateProgress(media: Media, number: String) {
|
||||
if (Anilist.userid != null) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val a = number.toFloatOrNull()?.roundToInt()
|
||||
if (a != media.userProgress) {
|
||||
Anilist.mutation.editList(
|
||||
media.id,
|
||||
a,
|
||||
status = if (media.userStatus == "REPEATING") media.userStatus else "CURRENT"
|
||||
)
|
||||
MAL.query.editList(
|
||||
media.idMAL,
|
||||
media.anime != null,
|
||||
a, null,
|
||||
if (media.userStatus == "REPEATING") media.userStatus!! else "CURRENT"
|
||||
)
|
||||
toast(currContext()?.getString(R.string.setting_progress, a))
|
||||
}
|
||||
media.userProgress = a
|
||||
Refresh.all()
|
||||
}
|
||||
} else {
|
||||
toast(currContext()?.getString(R.string.login_anilist_account))
|
||||
}
|
||||
}
|
143
app/src/main/java/ani/dantotsu/connections/anilist/Anilist.kt
Normal file
143
app/src/main/java/ani/dantotsu/connections/anilist/Anilist.kt
Normal file
|
@ -0,0 +1,143 @@
|
|||
package ani.dantotsu.connections.anilist
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import ani.dantotsu.R
|
||||
import ani.dantotsu.client
|
||||
import ani.dantotsu.currContext
|
||||
import ani.dantotsu.openLinkInBrowser
|
||||
import ani.dantotsu.tryWithSuspend
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
|
||||
object Anilist {
|
||||
val query: AnilistQueries = AnilistQueries()
|
||||
val mutation: AnilistMutations = AnilistMutations()
|
||||
|
||||
var token: String? = null
|
||||
var username: String? = null
|
||||
var adult: Boolean = false
|
||||
var userid: Int? = null
|
||||
var avatar: String? = null
|
||||
var bg: String? = null
|
||||
var episodesWatched: Int? = null
|
||||
var chapterRead: Int? = null
|
||||
|
||||
var genres: ArrayList<String>? = null
|
||||
var tags: Map<Boolean, List<String>>? = null
|
||||
|
||||
val sortBy = listOf(
|
||||
"SCORE_DESC","POPULARITY_DESC","TRENDING_DESC","TITLE_ENGLISH","TITLE_ENGLISH_DESC","SCORE"
|
||||
)
|
||||
|
||||
val seasons = listOf(
|
||||
"WINTER", "SPRING", "SUMMER", "FALL"
|
||||
)
|
||||
|
||||
val anime_formats = listOf(
|
||||
"TV", "TV SHORT", "MOVIE", "SPECIAL", "OVA", "ONA", "MUSIC"
|
||||
)
|
||||
|
||||
val manga_formats = listOf(
|
||||
"MANGA", "NOVEL", "ONE SHOT"
|
||||
)
|
||||
|
||||
val authorRoles = listOf(
|
||||
"Original Creator", "Story & Art", "Story"
|
||||
)
|
||||
|
||||
private val cal: Calendar = Calendar.getInstance()
|
||||
private val currentYear = cal.get(Calendar.YEAR)
|
||||
private val currentSeason: Int = when (cal.get(Calendar.MONTH)) {
|
||||
0, 1, 2 -> 0
|
||||
3, 4, 5 -> 1
|
||||
6, 7, 8 -> 2
|
||||
9, 10, 11 -> 3
|
||||
else -> 0
|
||||
}
|
||||
|
||||
private fun getSeason(next: Boolean): Pair<String, Int> {
|
||||
var newSeason = if (next) currentSeason + 1 else currentSeason - 1
|
||||
var newYear = currentYear
|
||||
if (newSeason > 3) {
|
||||
newSeason = 0
|
||||
newYear++
|
||||
} else if (newSeason < 0) {
|
||||
newSeason = 3
|
||||
newYear--
|
||||
}
|
||||
return seasons[newSeason] to newYear
|
||||
}
|
||||
|
||||
val currentSeasons = listOf(
|
||||
getSeason(false),
|
||||
seasons[currentSeason] to currentYear,
|
||||
getSeason(true)
|
||||
)
|
||||
|
||||
fun loginIntent(context: Context) {
|
||||
val clientID = 14959
|
||||
try {
|
||||
CustomTabsIntent.Builder().build().launchUrl(
|
||||
context,
|
||||
Uri.parse("https://anilist.co/api/v2/oauth/authorize?client_id=$clientID&response_type=token")
|
||||
)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
openLinkInBrowser("https://anilist.co/api/v2/oauth/authorize?client_id=$clientID&response_type=token")
|
||||
}
|
||||
}
|
||||
|
||||
fun getSavedToken(context: Context): Boolean {
|
||||
if ("anilistToken" in context.fileList()) {
|
||||
token = File(context.filesDir, "anilistToken").readText()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun removeSavedToken(context: Context) {
|
||||
token = null
|
||||
username = null
|
||||
adult = false
|
||||
userid = null
|
||||
avatar = null
|
||||
bg = null
|
||||
episodesWatched = null
|
||||
chapterRead = null
|
||||
if ("anilistToken" in context.fileList()) {
|
||||
File(context.filesDir, "anilistToken").delete()
|
||||
}
|
||||
}
|
||||
|
||||
suspend inline fun <reified T : Any> executeQuery(
|
||||
query: String,
|
||||
variables: String = "",
|
||||
force: Boolean = false,
|
||||
useToken: Boolean = true,
|
||||
show: Boolean = false,
|
||||
cache: Int? = null
|
||||
): T? {
|
||||
return tryWithSuspend {
|
||||
val data = mapOf(
|
||||
"query" to query,
|
||||
"variables" to variables
|
||||
)
|
||||
val headers = mutableMapOf(
|
||||
"Content-Type" to "application/json",
|
||||
"Accept" to "application/json"
|
||||
)
|
||||
|
||||
if (token != null || force) {
|
||||
if (token != null && useToken) headers["Authorization"] = "Bearer $token"
|
||||
|
||||
val json = client.post("https://graphql.anilist.co/", headers, data = data, cacheTime = cache ?: 10)
|
||||
if (!json.text.startsWith("{")) throw Exception(currContext()?.getString(R.string.anilist_down))
|
||||
if (show) println("Response : ${json.text}")
|
||||
json.parsed()
|
||||
} else null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
package ani.dantotsu.connections.anilist
|
||||
|
||||
import ani.dantotsu.connections.anilist.Anilist.executeQuery
|
||||
import ani.dantotsu.connections.anilist.api.FuzzyDate
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
class AnilistMutations {
|
||||
|
||||
suspend fun toggleFav(anime: Boolean = true, id: Int) {
|
||||
val query =
|
||||
"""mutation (${"$"}animeId: Int,${"$"}mangaId:Int) { ToggleFavourite(animeId:${"$"}animeId,mangaId:${"$"}mangaId){ anime { edges { id } } manga { edges { id } } } }"""
|
||||
val variables = if (anime) """{"animeId":"$id"}""" else """{"mangaId":"$id"}"""
|
||||
executeQuery<JsonObject>(query, variables)
|
||||
}
|
||||
|
||||
suspend fun editList(
|
||||
mediaID: Int,
|
||||
progress: Int? = null,
|
||||
score: Int? = null,
|
||||
repeat: Int? = null,
|
||||
notes: String? = null,
|
||||
status: String? = null,
|
||||
private:Boolean? = null,
|
||||
startedAt: FuzzyDate? = null,
|
||||
completedAt: FuzzyDate? = null,
|
||||
customList: List<String>? = null
|
||||
) {
|
||||
|
||||
val query = """
|
||||
mutation ( ${"$"}mediaID: Int, ${"$"}progress: Int,${"$"}private:Boolean,${"$"}repeat: Int, ${"$"}notes: String, ${"$"}customLists: [String], ${"$"}scoreRaw:Int, ${"$"}status:MediaListStatus, ${"$"}start:FuzzyDateInput${if (startedAt != null) "=" + startedAt.toVariableString() else ""}, ${"$"}completed:FuzzyDateInput${if (completedAt != null) "=" + completedAt.toVariableString() else ""} ) {
|
||||
SaveMediaListEntry( mediaId: ${"$"}mediaID, progress: ${"$"}progress, repeat: ${"$"}repeat, notes: ${"$"}notes, private: ${"$"}private, scoreRaw: ${"$"}scoreRaw, status:${"$"}status, startedAt: ${"$"}start, completedAt: ${"$"}completed , customLists: ${"$"}customLists ) {
|
||||
score(format:POINT_10_DECIMAL) startedAt{year month day} completedAt{year month day}
|
||||
}
|
||||
}
|
||||
""".replace("\n", "").replace(""" """, "")
|
||||
|
||||
val variables = """{"mediaID":$mediaID
|
||||
${if (private != null) ""","private":$private""" else ""}
|
||||
${if (progress != null) ""","progress":$progress""" else ""}
|
||||
${if (score != null) ""","scoreRaw":$score""" else ""}
|
||||
${if (repeat != null) ""","repeat":$repeat""" else ""}
|
||||
${if (notes != null) ""","notes":"${notes.replace("\n", "\\n")}"""" else ""}
|
||||
${if (status != null) ""","status":"$status"""" else ""}
|
||||
${if (customList !=null) ""","customLists":[${customList.joinToString { "\"$it\"" }}]""" else ""}
|
||||
}""".replace("\n", "").replace(""" """, "")
|
||||
println(variables)
|
||||
executeQuery<JsonObject>(query, variables, show = true)
|
||||
}
|
||||
|
||||
suspend fun deleteList(listId: Int) {
|
||||
val query = "mutation(${"$"}id:Int){DeleteMediaListEntry(id:${"$"}id){deleted}}"
|
||||
val variables = """{"id":"$listId"}"""
|
||||
executeQuery<JsonObject>(query, variables)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,925 @@
|
|||
package ani.dantotsu.connections.anilist
|
||||
|
||||
import android.app.Activity
|
||||
import ani.dantotsu.R
|
||||
import ani.dantotsu.connections.anilist.Anilist.authorRoles
|
||||
import ani.dantotsu.connections.anilist.Anilist.executeQuery
|
||||
import ani.dantotsu.connections.anilist.api.FuzzyDate
|
||||
import ani.dantotsu.connections.anilist.api.Page
|
||||
import ani.dantotsu.connections.anilist.api.Query
|
||||
import ani.dantotsu.checkGenreTime
|
||||
import ani.dantotsu.checkId
|
||||
import ani.dantotsu.currContext
|
||||
import ani.dantotsu.loadData
|
||||
import ani.dantotsu.logError
|
||||
import ani.dantotsu.media.Author
|
||||
import ani.dantotsu.media.Character
|
||||
import ani.dantotsu.media.Media
|
||||
import ani.dantotsu.media.Studio
|
||||
import ani.dantotsu.others.MalScraper
|
||||
import ani.dantotsu.saveData
|
||||
import ani.dantotsu.snackString
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
class AnilistQueries {
|
||||
suspend fun getUserData(): Boolean {
|
||||
val response: Query.Viewer?
|
||||
measureTimeMillis {
|
||||
response =
|
||||
executeQuery("""{Viewer{name options{displayAdultContent}avatar{medium}bannerImage id mediaListOptions{rowOrder animeList{sectionOrder customLists}mangaList{sectionOrder customLists}}statistics{anime{episodesWatched}manga{chaptersRead}}}}""")
|
||||
}.also { println("time : $it") }
|
||||
val user = response?.data?.user ?: return false
|
||||
|
||||
Anilist.userid = user.id
|
||||
Anilist.username = user.name
|
||||
Anilist.bg = user.bannerImage
|
||||
Anilist.avatar = user.avatar?.medium
|
||||
Anilist.episodesWatched = user.statistics?.anime?.episodesWatched
|
||||
Anilist.chapterRead = user.statistics?.manga?.chaptersRead
|
||||
Anilist.adult = user.options?.displayAdultContent ?: false
|
||||
return true
|
||||
}
|
||||
|
||||
suspend fun getMedia(id: Int, mal: Boolean = false): Media? {
|
||||
val response = executeQuery<Query.Media>(
|
||||
"""{Media(${if (!mal) "id:" else "idMal:"}$id){id idMal status chapters episodes nextAiringEpisode{episode}type meanScore isAdult isFavourite format bannerImage coverImage{large}title{english romaji userPreferred}mediaListEntry{progress private score(format:POINT_100)status}}}""",
|
||||
force = true
|
||||
)
|
||||
val fetchedMedia = response?.data?.media ?: return null
|
||||
return Media(fetchedMedia)
|
||||
}
|
||||
|
||||
fun mediaDetails(media: Media): Media {
|
||||
media.cameFromContinue = false
|
||||
|
||||
val query =
|
||||
"""{Media(id:${media.id}){id mediaListEntry{id status score(format:POINT_100) progress private notes repeat customLists updatedAt startedAt{year month day}completedAt{year month day}}isFavourite siteUrl idMal nextAiringEpisode{episode airingAt}source countryOfOrigin format duration season seasonYear startDate{year month day}endDate{year month day}genres studios(isMain:true){nodes{id name siteUrl}}description trailer { site id } synonyms tags { name rank isMediaSpoiler } characters(sort:[ROLE,FAVOURITES_DESC],perPage:25,page:1){edges{role node{id image{medium}name{userPreferred}}}}relations{edges{relationType(version:2)node{id idMal mediaListEntry{progress private score(format:POINT_100) status} episodes chapters nextAiringEpisode{episode} popularity meanScore isAdult isFavourite format title{english romaji userPreferred}type status(version:2)bannerImage coverImage{large}}}}staffPreview: staff(perPage: 8, sort: [RELEVANCE, ID]) {edges{role node{id name{userPreferred}}}}recommendations(sort:RATING_DESC){nodes{mediaRecommendation{id idMal mediaListEntry{progress private score(format:POINT_100) status} episodes chapters nextAiringEpisode{episode}meanScore isAdult isFavourite format title{english romaji userPreferred}type status(version:2)bannerImage coverImage{large}}}}externalLinks{url site}}}"""
|
||||
runBlocking {
|
||||
val anilist = async {
|
||||
var response = executeQuery<Query.Media>(query, force = true, show = true)
|
||||
if (response != null) {
|
||||
fun parse() {
|
||||
val fetchedMedia = response?.data?.media ?: return
|
||||
|
||||
media.source = fetchedMedia.source?.toString()
|
||||
media.countryOfOrigin = fetchedMedia.countryOfOrigin
|
||||
media.format = fetchedMedia.format?.toString()
|
||||
|
||||
media.startDate = fetchedMedia.startDate
|
||||
media.endDate = fetchedMedia.endDate
|
||||
|
||||
if (fetchedMedia.genres != null) {
|
||||
media.genres = arrayListOf()
|
||||
fetchedMedia.genres?.forEach { i ->
|
||||
media.genres.add(i)
|
||||
}
|
||||
}
|
||||
|
||||
media.trailer = fetchedMedia.trailer?.let { i ->
|
||||
if (i.site != null && i.site.toString() == "youtube")
|
||||
"https://www.youtube.com/embed/${i.id.toString().trim('"')}"
|
||||
else null
|
||||
}
|
||||
|
||||
fetchedMedia.synonyms?.apply {
|
||||
media.synonyms = arrayListOf()
|
||||
this.forEach { i ->
|
||||
media.synonyms.add(
|
||||
i
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fetchedMedia.tags?.apply {
|
||||
media.tags = arrayListOf()
|
||||
this.forEach { i ->
|
||||
if (i.isMediaSpoiler == false)
|
||||
media.tags.add("${i.name} : ${i.rank.toString()}%")
|
||||
}
|
||||
}
|
||||
|
||||
media.description = fetchedMedia.description.toString()
|
||||
|
||||
if (fetchedMedia.characters != null) {
|
||||
media.characters = arrayListOf()
|
||||
fetchedMedia.characters?.edges?.forEach { i ->
|
||||
i.node?.apply {
|
||||
media.characters?.add(
|
||||
Character(
|
||||
id = id,
|
||||
name = i.node?.name?.userPreferred,
|
||||
image = i.node?.image?.medium,
|
||||
banner = media.banner ?: media.cover,
|
||||
role = when (i.role.toString()){
|
||||
"MAIN" -> currContext()?.getString(R.string.main_role) ?: "MAIN"
|
||||
"SUPPORTING" -> currContext()?.getString(R.string.supporting_role) ?: "SUPPORTING"
|
||||
else -> i.role.toString()
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (fetchedMedia.relations != null) {
|
||||
media.relations = arrayListOf()
|
||||
fetchedMedia.relations?.edges?.forEach { mediaEdge ->
|
||||
val m = Media(mediaEdge)
|
||||
media.relations?.add(m)
|
||||
if (m.relation == "SEQUEL") {
|
||||
media.sequel = if ((media.sequel?.popularity ?: 0) < (m.popularity ?: 0)) m else media.sequel
|
||||
|
||||
} else if (m.relation == "PREQUEL") {
|
||||
media.prequel =
|
||||
if ((media.prequel?.popularity ?: 0) < (m.popularity ?: 0)) m else media.prequel
|
||||
}
|
||||
}
|
||||
media.relations?.sortByDescending { it.popularity }
|
||||
media.relations?.sortByDescending { it.startDate?.year }
|
||||
media.relations?.sortBy { it.relation }
|
||||
}
|
||||
if (fetchedMedia.recommendations != null) {
|
||||
media.recommendations = arrayListOf()
|
||||
fetchedMedia.recommendations?.nodes?.forEach { i ->
|
||||
i.mediaRecommendation?.apply {
|
||||
media.recommendations?.add(
|
||||
Media(this)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fetchedMedia.mediaListEntry != null) {
|
||||
fetchedMedia.mediaListEntry?.apply {
|
||||
media.userProgress = progress
|
||||
media.isListPrivate = private ?: false
|
||||
media.notes = notes
|
||||
media.userListId = id
|
||||
media.userScore = score?.toInt() ?: 0
|
||||
media.userStatus = status?.toString()
|
||||
media.inCustomListsOf = customLists?.toMutableMap()
|
||||
media.userRepeat = repeat ?: 0
|
||||
media.userUpdatedAt = updatedAt?.toString()?.toLong()?.times(1000)
|
||||
media.userCompletedAt = completedAt ?: FuzzyDate()
|
||||
media.userStartedAt = startedAt ?: FuzzyDate()
|
||||
}
|
||||
} else {
|
||||
media.isListPrivate = false
|
||||
media.userStatus = null
|
||||
media.userListId = null
|
||||
media.userProgress = null
|
||||
media.userScore = 0
|
||||
media.userRepeat = 0
|
||||
media.userUpdatedAt = null
|
||||
media.userCompletedAt = FuzzyDate()
|
||||
media.userStartedAt = FuzzyDate()
|
||||
}
|
||||
|
||||
if (media.anime != null) {
|
||||
media.anime.episodeDuration = fetchedMedia.duration
|
||||
media.anime.season = fetchedMedia.season?.toString()
|
||||
media.anime.seasonYear = fetchedMedia.seasonYear
|
||||
|
||||
fetchedMedia.studios?.nodes?.apply {
|
||||
if (isNotEmpty()) {
|
||||
val firstStudio = get(0)
|
||||
media.anime.mainStudio = Studio(
|
||||
firstStudio.id.toString(),
|
||||
firstStudio.name ?: "N/A"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fetchedMedia.staff?.edges?.find { authorRoles.contains(it.role?.trim()) }?.node?.let {
|
||||
media.anime.author = Author(
|
||||
it.id.toString(),
|
||||
it.name?.userPreferred ?: "N/A"
|
||||
)
|
||||
}
|
||||
|
||||
media.anime.nextAiringEpisodeTime = fetchedMedia.nextAiringEpisode?.airingAt?.toLong()
|
||||
|
||||
fetchedMedia.externalLinks?.forEach { i ->
|
||||
when (i.site.lowercase()) {
|
||||
"youtube" -> media.anime.youtube = i.url
|
||||
"crunchyroll" -> media.crunchySlug = i.url?.split("/")?.getOrNull(3)
|
||||
"vrv" -> media.vrvId = i.url?.split("/")?.getOrNull(4)
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (media.manga != null) {
|
||||
fetchedMedia.staff?.edges?.find { authorRoles.contains(it.role?.trim()) }?.node?.let {
|
||||
media.manga.author = Author(
|
||||
it.id.toString(),
|
||||
it.name?.userPreferred ?: "N/A"
|
||||
)
|
||||
}
|
||||
}
|
||||
media.shareLink = fetchedMedia.siteUrl
|
||||
}
|
||||
|
||||
if (response.data?.media != null) parse()
|
||||
else {
|
||||
snackString(currContext()?.getString(R.string.adult_stuff))
|
||||
response = executeQuery(query, force = true, useToken = false)
|
||||
if (response?.data?.media != null) parse()
|
||||
else snackString(currContext()?.getString(R.string.what_did_you_open))
|
||||
}
|
||||
} else {
|
||||
snackString(currContext()?.getString(R.string.error_getting_data))
|
||||
}
|
||||
}
|
||||
val mal = async {
|
||||
if (media.idMAL != null) {
|
||||
MalScraper.loadMedia(media)
|
||||
}
|
||||
}
|
||||
awaitAll(anilist, mal)
|
||||
}
|
||||
return media
|
||||
}
|
||||
|
||||
suspend fun continueMedia(type: String,planned:Boolean=false): ArrayList<Media> {
|
||||
val returnArray = arrayListOf<Media>()
|
||||
val map = mutableMapOf<Int, Media>()
|
||||
val statuses = if(!planned) arrayOf("CURRENT", "REPEATING") else arrayOf("PLANNING")
|
||||
suspend fun repeat(status: String) {
|
||||
val response =
|
||||
executeQuery<Query.MediaListCollection>(""" { MediaListCollection(userId: ${Anilist.userid}, type: $type, status: $status , sort: UPDATED_TIME ) { lists { entries { progress private score(format:POINT_100) status media { id idMal type isAdult status chapters episodes nextAiringEpisode {episode} meanScore isFavourite format bannerImage coverImage{large} title { english romaji userPreferred } } } } } } """)
|
||||
|
||||
response?.data?.mediaListCollection?.lists?.forEach { li ->
|
||||
li.entries?.reversed()?.forEach {
|
||||
val m = Media(it)
|
||||
m.cameFromContinue = true
|
||||
map[m.id] = m
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
statuses.forEach { repeat(it) }
|
||||
val set = loadData<MutableSet<Int>>("continue_$type")
|
||||
if (set != null) {
|
||||
set.reversed().forEach {
|
||||
if (map.containsKey(it)) returnArray.add(map[it]!!)
|
||||
}
|
||||
for (i in map) {
|
||||
if (i.value !in returnArray) returnArray.add(i.value)
|
||||
}
|
||||
} else returnArray.addAll(map.values)
|
||||
return returnArray
|
||||
}
|
||||
|
||||
suspend fun favMedia(anime: Boolean): ArrayList<Media> {
|
||||
var hasNextPage = true
|
||||
var page = 0
|
||||
|
||||
suspend fun getNextPage(page:Int): List<Media> {
|
||||
val response =
|
||||
executeQuery<Query.User>("""{User(id:${Anilist.userid}){id favourites{${if (anime) "anime" else "manga"}(page:$page){pageInfo{hasNextPage}edges{favouriteOrder node{id idMal isAdult mediaListEntry{ progress private score(format:POINT_100) status } chapters isFavourite format episodes nextAiringEpisode{episode}meanScore isFavourite format startDate{year month day} title{english romaji userPreferred}type status(version:2)bannerImage coverImage{large}}}}}}}""")
|
||||
val favourites = response?.data?.user?.favourites
|
||||
val apiMediaList = if (anime) favourites?.anime else favourites?.manga
|
||||
hasNextPage = apiMediaList?.pageInfo?.hasNextPage ?: false
|
||||
return apiMediaList?.edges?.mapNotNull {
|
||||
it.node?.let { i->
|
||||
Media(i).apply { isFav = true }
|
||||
}
|
||||
} ?: return listOf()
|
||||
}
|
||||
|
||||
val responseArray = arrayListOf<Media>()
|
||||
while(hasNextPage){
|
||||
page++
|
||||
responseArray.addAll(getNextPage(page))
|
||||
}
|
||||
return responseArray
|
||||
}
|
||||
|
||||
suspend fun recommendations(): ArrayList<Media> {
|
||||
val response =
|
||||
executeQuery<Query.Page>(""" { Page(page: 1, perPage:30) { pageInfo { total currentPage hasNextPage } recommendations(sort: RATING_DESC, onList: true) { rating userRating mediaRecommendation { id idMal isAdult mediaListEntry { progress private score(format:POINT_100) status } chapters isFavourite format episodes nextAiringEpisode {episode} popularity meanScore isFavourite format title {english romaji userPreferred } type status(version: 2) bannerImage coverImage { large } } } } } """)
|
||||
val map = mutableMapOf<Int, Media>()
|
||||
response?.data?.page?.apply {
|
||||
recommendations?.onEach {
|
||||
val json = it.mediaRecommendation
|
||||
if (json != null) {
|
||||
val m = Media(json)
|
||||
m.relation = json.type?.toString()
|
||||
map[m.id] = m
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val types = arrayOf("ANIME", "MANGA")
|
||||
suspend fun repeat(type: String) {
|
||||
val res =
|
||||
executeQuery<Query.MediaListCollection>(""" { MediaListCollection(userId: ${Anilist.userid}, type: $type, status: PLANNING , sort: MEDIA_POPULARITY_DESC ) { lists { entries { media { id mediaListEntry { progress private score(format:POINT_100) status } idMal type isAdult popularity status(version: 2) chapters episodes nextAiringEpisode {episode} meanScore isFavourite format bannerImage coverImage{large} title { english romaji userPreferred } } } } } } """)
|
||||
res?.data?.mediaListCollection?.lists?.forEach { li ->
|
||||
li.entries?.forEach {
|
||||
val m = Media(it)
|
||||
if (m.status == "RELEASING" || m.status == "FINISHED") {
|
||||
m.relation = it.media?.type?.toString()
|
||||
map[m.id] = m
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
types.forEach { repeat(it) }
|
||||
|
||||
val list = ArrayList(map.values.toList())
|
||||
list.sortByDescending { it.meanScore }
|
||||
return list
|
||||
}
|
||||
|
||||
private suspend fun bannerImage(type: String): String? {
|
||||
var image = loadData<BannerImage>("banner_$type")
|
||||
if (image == null || image.checkTime()) {
|
||||
val response =
|
||||
executeQuery<Query.MediaListCollection>("""{ MediaListCollection(userId: ${Anilist.userid}, type: $type, chunk:1,perChunk:25, sort: [SCORE_DESC,UPDATED_TIME_DESC]) { lists { entries{ media { id bannerImage } } } } } """)
|
||||
val random = response?.data?.mediaListCollection?.lists?.mapNotNull {
|
||||
it.entries?.mapNotNull { entry ->
|
||||
val imageUrl = entry.media?.bannerImage
|
||||
if (imageUrl != null && imageUrl != "null") imageUrl
|
||||
else null
|
||||
}
|
||||
}?.flatten()?.randomOrNull() ?: return null
|
||||
|
||||
image = BannerImage(
|
||||
random,
|
||||
System.currentTimeMillis()
|
||||
)
|
||||
saveData("banner_$type", image)
|
||||
return image.url
|
||||
} else return image.url
|
||||
}
|
||||
|
||||
suspend fun getBannerImages(): ArrayList<String?> {
|
||||
val default = arrayListOf<String?>(null, null)
|
||||
default[0] = bannerImage("ANIME")
|
||||
default[1] = bannerImage("MANGA")
|
||||
return default
|
||||
}
|
||||
|
||||
suspend fun getMediaLists(anime: Boolean, userId: Int, sortOrder: String? = null): MutableMap<String, ArrayList<Media>> {
|
||||
val response =
|
||||
executeQuery<Query.MediaListCollection>("""{ MediaListCollection(userId: $userId, type: ${if (anime) "ANIME" else "MANGA"}) { lists { name isCustomList entries { status progress private score(format:POINT_100) updatedAt media { id idMal isAdult type status chapters episodes nextAiringEpisode {episode} bannerImage meanScore isFavourite format coverImage{large} startDate{year month day} title {english romaji userPreferred } } } } user { id mediaListOptions { rowOrder animeList { sectionOrder } mangaList { sectionOrder } } } } }""")
|
||||
val sorted = mutableMapOf<String, ArrayList<Media>>()
|
||||
val unsorted = mutableMapOf<String, ArrayList<Media>>()
|
||||
val all = arrayListOf<Media>()
|
||||
val allIds = arrayListOf<Int>()
|
||||
|
||||
response?.data?.mediaListCollection?.lists?.forEach { i ->
|
||||
val name = i.name.toString().trim('"')
|
||||
unsorted[name] = arrayListOf()
|
||||
i.entries?.forEach {
|
||||
val a = Media(it)
|
||||
unsorted[name]?.add(a)
|
||||
if (!allIds.contains(a.id)) {
|
||||
allIds.add(a.id)
|
||||
all.add(a)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val options = response?.data?.mediaListCollection?.user?.mediaListOptions
|
||||
val mediaList = if (anime) options?.animeList else options?.mangaList
|
||||
mediaList?.sectionOrder?.forEach {
|
||||
if (unsorted.containsKey(it)) sorted[it] = unsorted[it]!!
|
||||
}
|
||||
unsorted.forEach {
|
||||
if(!sorted.containsKey(it.key)) sorted[it.key] = it.value
|
||||
}
|
||||
|
||||
sorted["Favourites"] = favMedia(anime)
|
||||
sorted["Favourites"]?.sortWith(compareBy { it.userFavOrder })
|
||||
|
||||
sorted["All"] = all
|
||||
|
||||
val sort = sortOrder ?: options?.rowOrder
|
||||
for (i in sorted.keys) {
|
||||
when (sort) {
|
||||
"score" -> sorted[i]?.sortWith { b, a -> compareValuesBy(a, b, { it.userScore }, { it.meanScore }) }
|
||||
"title" -> sorted[i]?.sortWith(compareBy { it.userPreferredName })
|
||||
"updatedAt" -> sorted[i]?.sortWith(compareByDescending { it.userUpdatedAt })
|
||||
"release" -> sorted[i]?.sortWith(compareByDescending { it.startDate })
|
||||
"id" -> sorted[i]?.sortWith(compareBy { it.id })
|
||||
}
|
||||
}
|
||||
return sorted
|
||||
}
|
||||
|
||||
|
||||
suspend fun getGenresAndTags(activity: Activity): Boolean {
|
||||
var genres: ArrayList<String>? = loadData("genres_list", activity)
|
||||
var tags: Map<Boolean, List<String>>? = loadData("tags_map", activity)
|
||||
|
||||
if (genres == null) {
|
||||
executeQuery<Query.GenreCollection>(
|
||||
"""{GenreCollection}""",
|
||||
force = true,
|
||||
useToken = false
|
||||
)?.data?.genreCollection?.apply {
|
||||
genres = arrayListOf()
|
||||
forEach {
|
||||
genres?.add(it)
|
||||
}
|
||||
saveData("genres_list", genres!!)
|
||||
}
|
||||
}
|
||||
if (tags == null) {
|
||||
executeQuery<Query.MediaTagCollection>(
|
||||
"""{ MediaTagCollection { name isAdult } }""",
|
||||
force = true
|
||||
)?.data?.mediaTagCollection?.apply {
|
||||
val adult = mutableListOf<String>()
|
||||
val good = mutableListOf<String>()
|
||||
forEach { node ->
|
||||
if (node.isAdult == true) adult.add(node.name)
|
||||
else good.add(node.name)
|
||||
}
|
||||
tags = mapOf(
|
||||
true to adult,
|
||||
false to good
|
||||
)
|
||||
saveData("tags_map", tags)
|
||||
}
|
||||
}
|
||||
return if (genres != null && tags != null) {
|
||||
Anilist.genres = genres
|
||||
Anilist.tags = tags
|
||||
true
|
||||
} else false
|
||||
}
|
||||
|
||||
suspend fun getGenres(genres: ArrayList<String>, listener: ((Pair<String, String>) -> Unit)) {
|
||||
genres.forEach {
|
||||
getGenreThumbnail(it).apply {
|
||||
if (this != null) {
|
||||
listener.invoke(it to this.thumbnail)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getGenreThumbnail(genre: String): Genre? {
|
||||
val genres = loadData<MutableMap<String, Genre>>("genre_thumb") ?: mutableMapOf()
|
||||
if (genres.checkGenreTime(genre)) {
|
||||
try {
|
||||
val genreQuery =
|
||||
"""{ Page(perPage: 10){media(genre:"$genre", sort: TRENDING_DESC, type: ANIME, countryOfOrigin:"JP") {id bannerImage title{english romaji userPreferred} } } }"""
|
||||
executeQuery<Query.Page>(genreQuery, force = true)?.data?.page?.media?.forEach {
|
||||
if (genres.checkId(it.id) && it.bannerImage != null) {
|
||||
genres[genre] = Genre(
|
||||
genre,
|
||||
it.id,
|
||||
it.bannerImage!!,
|
||||
System.currentTimeMillis()
|
||||
)
|
||||
saveData("genre_thumb", genres)
|
||||
return genres[genre]
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logError(e)
|
||||
}
|
||||
} else {
|
||||
return genres[genre]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun search(
|
||||
type: String,
|
||||
page: Int? = null,
|
||||
perPage: Int? = null,
|
||||
search: String? = null,
|
||||
sort: String? = null,
|
||||
genres: MutableList<String>? = null,
|
||||
tags: MutableList<String>? = null,
|
||||
format: String? = null,
|
||||
isAdult: Boolean = false,
|
||||
onList: Boolean? = null,
|
||||
excludedGenres: MutableList<String>? = null,
|
||||
excludedTags: MutableList<String>? = null,
|
||||
seasonYear: Int? = null,
|
||||
season: String? = null,
|
||||
id: Int? = null,
|
||||
hd: Boolean = false,
|
||||
): SearchResults? {
|
||||
val query = """
|
||||
query (${"$"}page: Int = 1, ${"$"}id: Int, ${"$"}type: MediaType, ${"$"}isAdult: Boolean = false, ${"$"}search: String, ${"$"}format: [MediaFormat], ${"$"}status: MediaStatus, ${"$"}countryOfOrigin: CountryCode, ${"$"}source: MediaSource, ${"$"}season: MediaSeason, ${"$"}seasonYear: Int, ${"$"}year: String, ${"$"}onList: Boolean, ${"$"}yearLesser: FuzzyDateInt, ${"$"}yearGreater: FuzzyDateInt, ${"$"}episodeLesser: Int, ${"$"}episodeGreater: Int, ${"$"}durationLesser: Int, ${"$"}durationGreater: Int, ${"$"}chapterLesser: Int, ${"$"}chapterGreater: Int, ${"$"}volumeLesser: Int, ${"$"}volumeGreater: Int, ${"$"}licensedBy: [String], ${"$"}isLicensed: Boolean, ${"$"}genres: [String], ${"$"}excludedGenres: [String], ${"$"}tags: [String], ${"$"}excludedTags: [String], ${"$"}minimumTagRank: Int, ${"$"}sort: [MediaSort] = [POPULARITY_DESC, SCORE_DESC]) {
|
||||
Page(page: ${"$"}page, perPage: ${perPage ?: 50}) {
|
||||
pageInfo {
|
||||
total
|
||||
perPage
|
||||
currentPage
|
||||
lastPage
|
||||
hasNextPage
|
||||
}
|
||||
media(id: ${"$"}id, type: ${"$"}type, season: ${"$"}season, format_in: ${"$"}format, status: ${"$"}status, countryOfOrigin: ${"$"}countryOfOrigin, source: ${"$"}source, search: ${"$"}search, onList: ${"$"}onList, seasonYear: ${"$"}seasonYear, startDate_like: ${"$"}year, startDate_lesser: ${"$"}yearLesser, startDate_greater: ${"$"}yearGreater, episodes_lesser: ${"$"}episodeLesser, episodes_greater: ${"$"}episodeGreater, duration_lesser: ${"$"}durationLesser, duration_greater: ${"$"}durationGreater, chapters_lesser: ${"$"}chapterLesser, chapters_greater: ${"$"}chapterGreater, volumes_lesser: ${"$"}volumeLesser, volumes_greater: ${"$"}volumeGreater, licensedBy_in: ${"$"}licensedBy, isLicensed: ${"$"}isLicensed, genre_in: ${"$"}genres, genre_not_in: ${"$"}excludedGenres, tag_in: ${"$"}tags, tag_not_in: ${"$"}excludedTags, minimumTagRank: ${"$"}minimumTagRank, sort: ${"$"}sort, isAdult: ${"$"}isAdult) {
|
||||
id
|
||||
idMal
|
||||
isAdult
|
||||
status
|
||||
chapters
|
||||
episodes
|
||||
nextAiringEpisode {
|
||||
episode
|
||||
}
|
||||
type
|
||||
genres
|
||||
meanScore
|
||||
isFavourite
|
||||
format
|
||||
bannerImage
|
||||
coverImage {
|
||||
large
|
||||
extraLarge
|
||||
}
|
||||
title {
|
||||
english
|
||||
romaji
|
||||
userPreferred
|
||||
}
|
||||
mediaListEntry {
|
||||
progress
|
||||
private
|
||||
score(format: POINT_100)
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""".replace("\n", " ").replace(""" """, "")
|
||||
val variables = """{"type":"$type","isAdult":$isAdult
|
||||
${if (onList != null) ""","onList":$onList""" else ""}
|
||||
${if (page != null) ""","page":"$page"""" else ""}
|
||||
${if (id != null) ""","id":"$id"""" else ""}
|
||||
${if (seasonYear != null) ""","seasonYear":"$seasonYear"""" else ""}
|
||||
${if (season != null) ""","season":"$season"""" else ""}
|
||||
${if (search != null) ""","search":"$search"""" else ""}
|
||||
${if (sort!=null) ""","sort":"$sort"""" else ""}
|
||||
${if (format != null) ""","format":"${format.replace(" ", "_")}"""" else ""}
|
||||
${if (genres?.isNotEmpty() == true) ""","genres":[${genres.joinToString { "\"$it\"" }}]""" else ""}
|
||||
${
|
||||
if (excludedGenres?.isNotEmpty() == true)
|
||||
""","excludedGenres":[${excludedGenres.joinToString { "\"${it.replace("Not ", "")}\"" }}]"""
|
||||
else ""
|
||||
}
|
||||
${if (tags?.isNotEmpty() == true) ""","tags":[${tags.joinToString { "\"$it\"" }}]""" else ""}
|
||||
${
|
||||
if (excludedTags?.isNotEmpty() == true)
|
||||
""","excludedTags":[${excludedTags.joinToString { "\"${it.replace("Not ", "")}\"" }}]"""
|
||||
else ""
|
||||
}
|
||||
}""".replace("\n", " ").replace(""" """, "")
|
||||
|
||||
val response = executeQuery<Query.Page>(query, variables, true)?.data?.page
|
||||
if (response?.media != null) {
|
||||
val responseArray = arrayListOf<Media>()
|
||||
response.media?.forEach { i ->
|
||||
val userStatus = i.mediaListEntry?.status.toString()
|
||||
val genresArr = arrayListOf<String>()
|
||||
if (i.genres != null) {
|
||||
i.genres?.forEach { genre ->
|
||||
genresArr.add(genre)
|
||||
}
|
||||
}
|
||||
val media = Media(i)
|
||||
if (!hd) media.cover = i.coverImage?.large
|
||||
media.relation = if (onList == true) userStatus else null
|
||||
media.genres = genresArr
|
||||
responseArray.add(media)
|
||||
}
|
||||
|
||||
val pageInfo = response.pageInfo ?: return null
|
||||
|
||||
return SearchResults(
|
||||
type = type,
|
||||
perPage = perPage,
|
||||
search = search,
|
||||
sort = sort,
|
||||
isAdult = isAdult,
|
||||
onList = onList,
|
||||
genres = genres,
|
||||
excludedGenres = excludedGenres,
|
||||
tags = tags,
|
||||
excludedTags = excludedTags,
|
||||
format = format,
|
||||
seasonYear = seasonYear,
|
||||
season = season,
|
||||
results = responseArray,
|
||||
page = pageInfo.currentPage.toString().toIntOrNull() ?: 0,
|
||||
hasNextPage = pageInfo.hasNextPage == true,
|
||||
)
|
||||
} else snackString(currContext()?.getString(R.string.empty_response))
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun recentlyUpdated(
|
||||
smaller: Boolean = true,
|
||||
greater: Long = 0,
|
||||
lesser: Long = System.currentTimeMillis() / 1000 - 10000
|
||||
): MutableList<Media>? {
|
||||
suspend fun execute(page:Int = 1):Page?{
|
||||
val query = """{
|
||||
Page(page:$page,perPage:50) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
total
|
||||
}
|
||||
airingSchedules(
|
||||
airingAt_greater: $greater
|
||||
airingAt_lesser: $lesser
|
||||
sort:TIME_DESC
|
||||
) {
|
||||
episode
|
||||
airingAt
|
||||
media {
|
||||
id
|
||||
idMal
|
||||
status
|
||||
chapters
|
||||
episodes
|
||||
nextAiringEpisode { episode }
|
||||
isAdult
|
||||
type
|
||||
meanScore
|
||||
isFavourite
|
||||
format
|
||||
bannerImage
|
||||
countryOfOrigin
|
||||
coverImage { large }
|
||||
title {
|
||||
english
|
||||
romaji
|
||||
userPreferred
|
||||
}
|
||||
mediaListEntry {
|
||||
progress
|
||||
private
|
||||
score(format: POINT_100)
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}""".replace("\n", " ").replace(""" """, "")
|
||||
return executeQuery<Query.Page>(query, force = true)?.data?.page
|
||||
}
|
||||
if(smaller) {
|
||||
val response = execute()?.airingSchedules ?: return null
|
||||
val idArr = mutableListOf<Int>()
|
||||
val listOnly = loadData("recently_list_only") ?: false
|
||||
return response.mapNotNull { i ->
|
||||
i.media?.let {
|
||||
if (!idArr.contains(it.id))
|
||||
if (!listOnly && (it.countryOfOrigin == "JP" && (if (!Anilist.adult) it.isAdult == false else true)) || (listOnly && it.mediaListEntry != null)) {
|
||||
idArr.add(it.id)
|
||||
Media(it)
|
||||
} else null
|
||||
else null
|
||||
}
|
||||
}.toMutableList()
|
||||
}else{
|
||||
var i = 1
|
||||
val list = mutableListOf<Media>()
|
||||
var res : Page? = null
|
||||
suspend fun next(){
|
||||
res = execute(i)
|
||||
list.addAll(res?.airingSchedules?.mapNotNull { j ->
|
||||
j.media?.let {
|
||||
if (it.countryOfOrigin == "JP" && (if (!Anilist.adult) it.isAdult == false else true)) {
|
||||
Media(it).apply { relation = "${j.episode},${j.airingAt}" }
|
||||
} else null
|
||||
}
|
||||
}?: listOf())
|
||||
}
|
||||
next()
|
||||
while (res?.pageInfo?.hasNextPage == true){
|
||||
next()
|
||||
i++
|
||||
}
|
||||
return list.reversed().toMutableList()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getCharacterDetails(character: Character): Character {
|
||||
val query = """ {
|
||||
Character(id: ${character.id}) {
|
||||
id
|
||||
age
|
||||
gender
|
||||
description
|
||||
dateOfBirth {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
media(page: 0,sort:[POPULARITY_DESC,SCORE_DESC]) {
|
||||
pageInfo {
|
||||
total
|
||||
perPage
|
||||
currentPage
|
||||
lastPage
|
||||
hasNextPage
|
||||
}
|
||||
edges {
|
||||
id
|
||||
characterRole
|
||||
node {
|
||||
id
|
||||
idMal
|
||||
isAdult
|
||||
status
|
||||
chapters
|
||||
episodes
|
||||
nextAiringEpisode { episode }
|
||||
type
|
||||
meanScore
|
||||
isFavourite
|
||||
format
|
||||
bannerImage
|
||||
countryOfOrigin
|
||||
coverImage { large }
|
||||
title {
|
||||
english
|
||||
romaji
|
||||
userPreferred
|
||||
}
|
||||
mediaListEntry {
|
||||
progress
|
||||
private
|
||||
score(format: POINT_100)
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}""".replace("\n", " ").replace(""" """, "")
|
||||
executeQuery<Query.Character>(query, force = true)?.data?.character?.apply {
|
||||
character.age = age
|
||||
character.gender = gender
|
||||
character.description = description
|
||||
character.dateOfBirth = dateOfBirth
|
||||
character.roles = arrayListOf()
|
||||
media?.edges?.forEach { i ->
|
||||
val m = Media(i)
|
||||
m.relation = i.characterRole.toString()
|
||||
character.roles?.add(m)
|
||||
}
|
||||
}
|
||||
return character
|
||||
}
|
||||
|
||||
suspend fun getStudioDetails(studio: Studio): Studio {
|
||||
fun query(page: Int = 0) = """ {
|
||||
Studio(id: ${studio.id}) {
|
||||
id
|
||||
media(page: $page,sort:START_DATE_DESC) {
|
||||
pageInfo{
|
||||
hasNextPage
|
||||
}
|
||||
edges {
|
||||
id
|
||||
node {
|
||||
id
|
||||
idMal
|
||||
isAdult
|
||||
status
|
||||
chapters
|
||||
episodes
|
||||
nextAiringEpisode { episode }
|
||||
type
|
||||
meanScore
|
||||
startDate{ year }
|
||||
isFavourite
|
||||
format
|
||||
bannerImage
|
||||
countryOfOrigin
|
||||
coverImage { large }
|
||||
title {
|
||||
english
|
||||
romaji
|
||||
userPreferred
|
||||
}
|
||||
mediaListEntry {
|
||||
progress
|
||||
private
|
||||
score(format: POINT_100)
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}""".replace("\n", " ").replace(""" """, "")
|
||||
|
||||
var hasNextPage = true
|
||||
val yearMedia = mutableMapOf<String, ArrayList<Media>>()
|
||||
var page = 0
|
||||
while (hasNextPage) {
|
||||
page++
|
||||
hasNextPage = executeQuery<Query.Studio>(query(page), force = true)?.data?.studio?.media?.let {
|
||||
it.edges?.forEach { i ->
|
||||
i.node?.apply {
|
||||
val status = status.toString()
|
||||
val year = startDate?.year?.toString() ?: "TBA"
|
||||
val title = if (status != "CANCELLED") year else status
|
||||
if (!yearMedia.containsKey(title))
|
||||
yearMedia[title] = arrayListOf()
|
||||
yearMedia[title]?.add(Media(this))
|
||||
}
|
||||
}
|
||||
it.pageInfo?.hasNextPage == true
|
||||
} ?: false
|
||||
}
|
||||
if (yearMedia.contains("CANCELLED")) {
|
||||
val a = yearMedia["CANCELLED"]!!
|
||||
yearMedia.remove("CANCELLED")
|
||||
yearMedia["CANCELLED"] = a
|
||||
}
|
||||
studio.yearMedia = yearMedia
|
||||
return studio
|
||||
}
|
||||
|
||||
|
||||
suspend fun getAuthorDetails(author: Author): Author {
|
||||
fun query(page: Int = 0) = """ {
|
||||
Staff(id: ${author.id}) {
|
||||
id
|
||||
staffMedia(page: $page,sort:START_DATE_DESC) {
|
||||
pageInfo{
|
||||
hasNextPage
|
||||
}
|
||||
edges {
|
||||
staffRole
|
||||
id
|
||||
node {
|
||||
id
|
||||
idMal
|
||||
isAdult
|
||||
status
|
||||
chapters
|
||||
episodes
|
||||
nextAiringEpisode { episode }
|
||||
type
|
||||
meanScore
|
||||
startDate{ year }
|
||||
isFavourite
|
||||
format
|
||||
bannerImage
|
||||
countryOfOrigin
|
||||
coverImage { large }
|
||||
title {
|
||||
english
|
||||
romaji
|
||||
userPreferred
|
||||
}
|
||||
mediaListEntry {
|
||||
progress
|
||||
private
|
||||
score(format: POINT_100)
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}""".replace("\n", " ").replace(""" """, "")
|
||||
|
||||
var hasNextPage = true
|
||||
val yearMedia = mutableMapOf<String, ArrayList<Media>>()
|
||||
var page = 0
|
||||
|
||||
while (hasNextPage) {
|
||||
page++
|
||||
hasNextPage = executeQuery<Query.Author>(query(page), force = true)?.data?.author?.staffMedia?.let {
|
||||
it.edges?.forEach { i ->
|
||||
i.node?.apply {
|
||||
val status = status.toString()
|
||||
val year = startDate?.year?.toString() ?: "TBA"
|
||||
val title = if (status != "CANCELLED") year else status
|
||||
if (!yearMedia.containsKey(title))
|
||||
yearMedia[title] = arrayListOf()
|
||||
val media = Media(this)
|
||||
media.relation = i.staffRole
|
||||
yearMedia[title]?.add(media)
|
||||
}
|
||||
}
|
||||
it.pageInfo?.hasNextPage == true
|
||||
} ?: false
|
||||
}
|
||||
|
||||
if (yearMedia.contains("CANCELLED")) {
|
||||
val a = yearMedia["CANCELLED"]!!
|
||||
yearMedia.remove("CANCELLED")
|
||||
yearMedia["CANCELLED"] = a
|
||||
}
|
||||
author.yearMedia = yearMedia
|
||||
return author
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,276 @@
|
|||
package ani.dantotsu.connections.anilist
|
||||
|
||||
import android.content.Context
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import ani.dantotsu.R
|
||||
import ani.dantotsu.connections.discord.Discord
|
||||
import ani.dantotsu.loadData
|
||||
import ani.dantotsu.connections.mal.MAL
|
||||
import ani.dantotsu.media.Media
|
||||
import ani.dantotsu.others.AppUpdater
|
||||
import ani.dantotsu.snackString
|
||||
import ani.dantotsu.tryWithSuspend
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
suspend fun getUserId(context: Context, block: () -> Unit) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
if (Discord.userid == null && Discord.token != null) {
|
||||
if (!Discord.getUserData())
|
||||
snackString(context.getString(R.string.error_loading_discord_user_data))
|
||||
}
|
||||
}
|
||||
|
||||
val anilist = if (Anilist.userid == null && Anilist.token != null) {
|
||||
if (Anilist.query.getUserData()) {
|
||||
tryWithSuspend {
|
||||
if (MAL.token != null && !MAL.query.getUserData())
|
||||
snackString(context.getString(R.string.error_loading_mal_user_data))
|
||||
}
|
||||
true
|
||||
} else {
|
||||
snackString(context.getString(R.string.error_loading_anilist_user_data))
|
||||
false
|
||||
}
|
||||
} else true
|
||||
|
||||
if(anilist) block.invoke()
|
||||
}
|
||||
|
||||
class AnilistHomeViewModel : ViewModel() {
|
||||
private val listImages: MutableLiveData<ArrayList<String?>> = MutableLiveData<ArrayList<String?>>(arrayListOf())
|
||||
fun getListImages(): LiveData<ArrayList<String?>> = listImages
|
||||
suspend fun setListImages() = listImages.postValue(Anilist.query.getBannerImages())
|
||||
|
||||
private val animeContinue: MutableLiveData<ArrayList<Media>> = MutableLiveData<ArrayList<Media>>(null)
|
||||
fun getAnimeContinue(): LiveData<ArrayList<Media>> = animeContinue
|
||||
suspend fun setAnimeContinue() = animeContinue.postValue(Anilist.query.continueMedia("ANIME"))
|
||||
|
||||
private val animeFav: MutableLiveData<ArrayList<Media>> = MutableLiveData<ArrayList<Media>>(null)
|
||||
fun getAnimeFav(): LiveData<ArrayList<Media>> = animeFav
|
||||
suspend fun setAnimeFav() = animeFav.postValue(Anilist.query.favMedia(true))
|
||||
|
||||
private val animePlanned: MutableLiveData<ArrayList<Media>> = MutableLiveData<ArrayList<Media>>(null)
|
||||
fun getAnimePlanned(): LiveData<ArrayList<Media>> = animePlanned
|
||||
suspend fun setAnimePlanned() = animePlanned.postValue(Anilist.query.continueMedia("ANIME", true))
|
||||
|
||||
private val mangaContinue: MutableLiveData<ArrayList<Media>> = MutableLiveData<ArrayList<Media>>(null)
|
||||
fun getMangaContinue(): LiveData<ArrayList<Media>> = mangaContinue
|
||||
suspend fun setMangaContinue() = mangaContinue.postValue(Anilist.query.continueMedia("MANGA"))
|
||||
|
||||
private val mangaFav: MutableLiveData<ArrayList<Media>> = MutableLiveData<ArrayList<Media>>(null)
|
||||
fun getMangaFav(): LiveData<ArrayList<Media>> = mangaFav
|
||||
suspend fun setMangaFav() = mangaFav.postValue(Anilist.query.favMedia(false))
|
||||
|
||||
private val mangaPlanned: MutableLiveData<ArrayList<Media>> = MutableLiveData<ArrayList<Media>>(null)
|
||||
fun getMangaPlanned(): LiveData<ArrayList<Media>> = mangaPlanned
|
||||
suspend fun setMangaPlanned() = mangaPlanned.postValue(Anilist.query.continueMedia("MANGA", true))
|
||||
|
||||
private val recommendation: MutableLiveData<ArrayList<Media>> = MutableLiveData<ArrayList<Media>>(null)
|
||||
fun getRecommendation(): LiveData<ArrayList<Media>> = recommendation
|
||||
suspend fun setRecommendation() = recommendation.postValue(Anilist.query.recommendations())
|
||||
|
||||
suspend fun loadMain(context: FragmentActivity) {
|
||||
Anilist.getSavedToken(context)
|
||||
MAL.getSavedToken(context)
|
||||
Discord.getSavedToken(context)
|
||||
if (loadData<Boolean>("check_update") != false) AppUpdater.check(context)
|
||||
genres.postValue(Anilist.query.getGenresAndTags(context))
|
||||
}
|
||||
|
||||
val empty = MutableLiveData<Boolean>(null)
|
||||
|
||||
var loaded: Boolean = false
|
||||
val genres: MutableLiveData<Boolean?> = MutableLiveData(null)
|
||||
}
|
||||
|
||||
class AnilistAnimeViewModel : ViewModel() {
|
||||
var searched = false
|
||||
var notSet = true
|
||||
lateinit var searchResults: SearchResults
|
||||
private val type = "ANIME"
|
||||
private val trending: MutableLiveData<MutableList<Media>> = MutableLiveData<MutableList<Media>>(null)
|
||||
fun getTrending(): LiveData<MutableList<Media>> = trending
|
||||
suspend fun loadTrending(i: Int) {
|
||||
val (season, year) = Anilist.currentSeasons[i]
|
||||
trending.postValue(
|
||||
Anilist.query.search(
|
||||
type,
|
||||
perPage = 12,
|
||||
sort = Anilist.sortBy[2],
|
||||
season = season,
|
||||
seasonYear = year,
|
||||
hd = true
|
||||
)?.results
|
||||
)
|
||||
}
|
||||
|
||||
private val updated: MutableLiveData<MutableList<Media>> = MutableLiveData<MutableList<Media>>(null)
|
||||
fun getUpdated(): LiveData<MutableList<Media>> = updated
|
||||
suspend fun loadUpdated() = updated.postValue(Anilist.query.recentlyUpdated())
|
||||
|
||||
private val animePopular = MutableLiveData<SearchResults?>(null)
|
||||
fun getPopular(): LiveData<SearchResults?> = animePopular
|
||||
suspend fun loadPopular(
|
||||
type: String,
|
||||
search_val: String? = null,
|
||||
genres: ArrayList<String>? = null,
|
||||
sort: String = Anilist.sortBy[1],
|
||||
onList: Boolean = true,
|
||||
) {
|
||||
animePopular.postValue(
|
||||
Anilist.query.search(
|
||||
type,
|
||||
search = search_val,
|
||||
onList = if (onList) null else false,
|
||||
sort = sort,
|
||||
genres = genres
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
suspend fun loadNextPage(r: SearchResults) = animePopular.postValue(
|
||||
Anilist.query.search(
|
||||
r.type,
|
||||
r.page + 1,
|
||||
r.perPage,
|
||||
r.search,
|
||||
r.sort,
|
||||
r.genres,
|
||||
r.tags,
|
||||
r.format,
|
||||
r.isAdult,
|
||||
r.onList
|
||||
)
|
||||
)
|
||||
|
||||
var loaded: Boolean = false
|
||||
}
|
||||
|
||||
class AnilistMangaViewModel : ViewModel() {
|
||||
var searched = false
|
||||
var notSet = true
|
||||
lateinit var searchResults: SearchResults
|
||||
private val type = "MANGA"
|
||||
private val trending: MutableLiveData<MutableList<Media>> = MutableLiveData<MutableList<Media>>(null)
|
||||
fun getTrending(): LiveData<MutableList<Media>> = trending
|
||||
suspend fun loadTrending() =
|
||||
trending.postValue(Anilist.query.search(type, perPage = 10, sort = Anilist.sortBy[2], hd = true)?.results)
|
||||
|
||||
private val updated: MutableLiveData<MutableList<Media>> = MutableLiveData<MutableList<Media>>(null)
|
||||
fun getTrendingNovel(): LiveData<MutableList<Media>> = updated
|
||||
suspend fun loadTrendingNovel() =
|
||||
updated.postValue(Anilist.query.search(type, perPage = 10, sort = Anilist.sortBy[2], format = "NOVEL")?.results)
|
||||
|
||||
private val mangaPopular = MutableLiveData<SearchResults?>(null)
|
||||
fun getPopular(): LiveData<SearchResults?> = mangaPopular
|
||||
suspend fun loadPopular(
|
||||
type: String,
|
||||
search_val: String? = null,
|
||||
genres: ArrayList<String>? = null,
|
||||
sort: String = Anilist.sortBy[1],
|
||||
onList: Boolean = true,
|
||||
) {
|
||||
mangaPopular.postValue(
|
||||
Anilist.query.search(
|
||||
type,
|
||||
search = search_val,
|
||||
onList = if (onList) null else false,
|
||||
sort = sort,
|
||||
genres = genres
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
suspend fun loadNextPage(r: SearchResults) = mangaPopular.postValue(
|
||||
Anilist.query.search(
|
||||
r.type,
|
||||
r.page + 1,
|
||||
r.perPage,
|
||||
r.search,
|
||||
r.sort,
|
||||
r.genres,
|
||||
r.tags,
|
||||
r.format,
|
||||
r.isAdult,
|
||||
r.onList,
|
||||
r.excludedGenres,
|
||||
r.excludedTags,
|
||||
r.seasonYear,
|
||||
r.season
|
||||
)
|
||||
)
|
||||
|
||||
var loaded: Boolean = false
|
||||
}
|
||||
|
||||
class AnilistSearch : ViewModel() {
|
||||
var searched = false
|
||||
var notSet = true
|
||||
lateinit var searchResults: SearchResults
|
||||
private val result: MutableLiveData<SearchResults?> = MutableLiveData<SearchResults?>(null)
|
||||
|
||||
fun getSearch(): LiveData<SearchResults?> = result
|
||||
suspend fun loadSearch(r: SearchResults) = result.postValue(
|
||||
Anilist.query.search(
|
||||
r.type,
|
||||
r.page,
|
||||
r.perPage,
|
||||
r.search,
|
||||
r.sort,
|
||||
r.genres,
|
||||
r.tags,
|
||||
r.format,
|
||||
r.isAdult,
|
||||
r.onList,
|
||||
r.excludedGenres,
|
||||
r.excludedTags,
|
||||
r.seasonYear,
|
||||
r.season
|
||||
)
|
||||
)
|
||||
|
||||
suspend fun loadNextPage(r: SearchResults) = result.postValue(
|
||||
Anilist.query.search(
|
||||
r.type,
|
||||
r.page + 1,
|
||||
r.perPage,
|
||||
r.search,
|
||||
r.sort,
|
||||
r.genres,
|
||||
r.tags,
|
||||
r.format,
|
||||
r.isAdult,
|
||||
r.onList,
|
||||
r.excludedGenres,
|
||||
r.excludedTags,
|
||||
r.seasonYear,
|
||||
r.season
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
class GenresViewModel : ViewModel() {
|
||||
var genres: MutableMap<String, String>? = null
|
||||
var done = false
|
||||
var doneListener: (() -> Unit)? = null
|
||||
suspend fun loadGenres(genre: ArrayList<String>, listener: (Pair<String, String>) -> Unit) {
|
||||
if (genres == null) {
|
||||
genres = mutableMapOf()
|
||||
Anilist.query.getGenres(genre) {
|
||||
genres!![it.first] = it.second
|
||||
listener.invoke(it)
|
||||
if (genres!!.size == genre.size) {
|
||||
done = true
|
||||
doneListener?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
package ani.dantotsu.connections.anilist
|
||||
|
||||
import java.io.Serializable
|
||||
|
||||
data class BannerImage(
|
||||
val url: String?,
|
||||
var time: Long,
|
||||
) : Serializable {
|
||||
fun checkTime(): Boolean {
|
||||
return (System.currentTimeMillis() - time) >= (1000 * 60 * 60 * 6)
|
||||
}
|
||||
}
|
10
app/src/main/java/ani/dantotsu/connections/anilist/Genre.kt
Normal file
10
app/src/main/java/ani/dantotsu/connections/anilist/Genre.kt
Normal file
|
@ -0,0 +1,10 @@
|
|||
package ani.dantotsu.connections.anilist
|
||||
|
||||
import java.io.Serializable
|
||||
|
||||
data class Genre(
|
||||
val name: String,
|
||||
var id: Int,
|
||||
var thumbnail: String,
|
||||
var time: Long,
|
||||
) : Serializable
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue