refactor: Modularize the project

This commit is contained in:
Ahmad Ansori Palembani 2024-06-13 19:50:13 +07:00
parent 7e7a37bc53
commit 2df2780912
Signed by: null2264
GPG key ID: BA64F8B60AF3EFB6
85 changed files with 1358 additions and 248 deletions

View file

@ -39,14 +39,9 @@ val buildTime: String by lazy {
val supportedAbis = setOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
android {
compileSdk = AndroidConfig.compileSdk
ndkVersion = AndroidConfig.ndk
defaultConfig {
minSdk = AndroidConfig.minSdk
targetSdk = AndroidConfig.targetSdk
applicationId = "eu.kanade.tachiyomi"
versionCode = 136
versionCode = 137
versionName = "1.8.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
multiDexEnabled = true
@ -145,14 +140,6 @@ android {
kotlinCompilerExtensionVersion = compose.versions.compose.compiler.get()
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
isCoreLibraryDesugaringEnabled = true
}
kotlinOptions {
jvmTarget = "17"
}
namespace = "eu.kanade.tachiyomi"
sqldelight {
@ -167,6 +154,9 @@ android {
}
dependencies {
implementation(projects.core)
implementation(projects.sourceApi)
// Compose
implementation(compose.bundles.compose)
debugImplementation(compose.ui.tooling)
@ -280,8 +270,8 @@ dependencies {
implementation(kotlin("stdlib", org.jetbrains.kotlin.config.KotlinCompilerVersion.VERSION))
implementation(kotlinx.coroutines.core)
implementation(kotlinx.coroutines.android)
implementation(platform(kotlinx.coroutines.bom))
implementation(kotlinx.bundles.coroutines)
// Text distance
implementation(libs.java.string.similarity)
@ -296,8 +286,6 @@ dependencies {
implementation(kotlinx.immutable)
"coreLibraryDesugaring"(libs.desugar)
// Tests
testImplementation(libs.bundles.test)
testRuntimeOnly(libs.bundles.test.runtime)
@ -306,13 +294,6 @@ dependencies {
}
tasks {
withType<Test> {
useJUnitPlatform()
testLogging {
events("passed", "skipped", "failed")
}
}
// See https://kotlinlang.org/docs/reference/experimental.html#experimental-status-of-experimental-api(-markers)
withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
kotlinOptions.freeCompilerArgs += listOf(

View file

@ -4,6 +4,7 @@
-keep,allowoptimization class eu.kanade.** { public protected *; }
-keep,allowoptimization class tachiyomi.** { public protected *; }
-keep,allowoptimization class dev.yokai.** { public protected *; }
-keep,allowoptimization class yokai.** { public protected *; }
# Keep common dependencies used in extensions
-keep class androidx.preference.** { public protected *; }

View file

@ -5,11 +5,13 @@ import androidx.core.content.ContextCompat
import androidx.sqlite.db.SupportSQLiteOpenHelper
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
import com.chuckerteam.chucker.api.ChuckerCollector
import com.chuckerteam.chucker.api.ChuckerInterceptor
import dev.yokai.data.AndroidDatabaseHandler
import dev.yokai.data.DatabaseHandler
import dev.yokai.domain.SplashState
import dev.yokai.domain.extension.interactor.TrustExtension
import dev.yokai.domain.storage.StorageManager
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.core.storage.AndroidStorageFolderProvider
import eu.kanade.tachiyomi.data.cache.ChapterCache
import eu.kanade.tachiyomi.data.cache.CoverCache
@ -89,7 +91,23 @@ class AppModule(val app: Application) : InjektModule {
addSingletonFactory { CoverCache(app) }
addSingletonFactory { NetworkHelper(app) }
addSingletonFactory {
NetworkHelper(
app,
get(),
) { builder ->
if (BuildConfig.DEBUG) {
builder.addInterceptor(
ChuckerInterceptor.Builder(app)
.collector(ChuckerCollector(app))
.maxContentLength(250000L)
.redactHeaders(emptySet())
.alwaysReadResponseBody(false)
.build(),
)
}
}
}
addSingletonFactory { JavaScriptEngine(app) }

View file

@ -13,6 +13,7 @@ import eu.kanade.tachiyomi.core.preference.PreferenceStore
import eu.kanade.tachiyomi.core.storage.AndroidStorageFolderProvider
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.track.TrackPreferences
import eu.kanade.tachiyomi.network.NetworkPreferences
import uy.kohesive.injekt.api.InjektModule
import uy.kohesive.injekt.api.InjektRegistrar
import uy.kohesive.injekt.api.addSingletonFactory
@ -36,6 +37,8 @@ class PreferenceModule(val application: Application) : InjektModule {
addSingletonFactory { DownloadPreferences(get()) }
addSingletonFactory { NetworkPreferences(get()) }
addSingletonFactory {
PreferencesHelper(
context = application,

View file

@ -36,4 +36,5 @@ val migrations: ImmutableList<Migration> = persistentListOf(
ExtensionInstallerEnumMigration(),
CutoutMigration(),
RepoJsonMigration(),
NetworkPrefsMigration(),
)

View file

@ -0,0 +1,23 @@
package dev.yokai.core.migration.migrations
import androidx.preference.PreferenceManager
import dev.yokai.core.migration.Migration
import dev.yokai.core.migration.MigrationContext
import eu.kanade.tachiyomi.App
import eu.kanade.tachiyomi.network.NetworkPreferences
class NetworkPrefsMigration : Migration {
override val version: Float = 137f
override suspend fun invoke(migrationContext: MigrationContext): Boolean {
val context: App = migrationContext.get() ?: return false
val preferences: NetworkPreferences = migrationContext.get() ?: return false
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
val dohProvider = prefs.getInt("doh_provider", -1)
if (dohProvider > -1) {
preferences.dohProvider().set(dohProvider)
}
return true
}
}

View file

@ -0,0 +1,13 @@
package eu.kanade.tachiyomi.core.storage.preference
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import eu.kanade.tachiyomi.core.preference.Preference
@Composable
fun <T> Preference<T>.collectAsState(): State<T> {
val flow = remember(this) { changes() }
return flow.collectAsState(initial = get())
}

View file

@ -1,5 +1,18 @@
package eu.kanade.tachiyomi.data.database.models
import eu.kanade.tachiyomi.source.model.SChapter
fun SChapter.toChapter(): ChapterImpl {
return ChapterImpl().apply {
name = this@SChapter.name
url = this@SChapter.url
date_upload = this@SChapter.date_upload
chapter_number = this@SChapter.chapter_number
scanlator = this@SChapter.scanlator
}
}
class ChapterImpl : Chapter {
override var id: Long? = null

View file

@ -12,7 +12,7 @@ import eu.kanade.tachiyomi.ui.reader.settings.ReadingModeType
import eu.kanade.tachiyomi.util.manga.MangaCoverMetadata
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.Locale
import java.util.*
interface Manga : SManga {
@ -34,6 +34,53 @@ interface Manga : SManga {
var filtered_scanlators: String?
val originalTitle: String
get() = (this as? MangaImpl)?.ogTitle ?: title
val originalAuthor: String?
get() = (this as? MangaImpl)?.ogAuthor ?: author
val originalArtist: String?
get() = (this as? MangaImpl)?.ogArtist ?: artist
val originalDescription: String?
get() = (this as? MangaImpl)?.ogDesc ?: description
val originalGenre: String?
get() = (this as? MangaImpl)?.ogGenre ?: genre
val originalStatus: Int
get() = (this as? MangaImpl)?.ogStatus ?: status
val hasSameAuthorAndArtist: Boolean
get() = author == artist || artist.isNullOrBlank() ||
author?.contains(artist ?: "", true) == true
fun copyFrom(other: Manga) {
if (other.author != null) {
author = other.originalAuthor
}
if (other.artist != null) {
artist = other.originalArtist
}
if (other.description != null) {
description = other.originalDescription
}
if (other.genre != null) {
genre = other.originalGenre
}
if (other.thumbnail_url != null) {
thumbnail_url = other.thumbnail_url
}
status = other.originalStatus
update_strategy = other.update_strategy
if (!initialized) {
initialized = other.initialized
}
}
fun isBlank() = id == Long.MIN_VALUE
fun isHidden() = status == -1

View file

@ -11,7 +11,6 @@ import eu.kanade.tachiyomi.core.preference.getEnum
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.updater.AppDownloadInstallJob
import eu.kanade.tachiyomi.extension.model.InstalledExtensionsOrder
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.ui.library.LibraryItem
import eu.kanade.tachiyomi.ui.library.filter.FilterBottomSheet
import eu.kanade.tachiyomi.ui.reader.settings.OrientationType
@ -401,10 +400,6 @@ class PreferencesHelper(val context: Context, val preferenceStore: PreferenceSto
fun useLargeToolbar() = preferenceStore.getBoolean("use_large_toolbar", true)
fun dohProvider() = prefs.getInt(Keys.dohProvider, -1)
fun defaultUserAgent() = preferenceStore.getString("default_user_agent", NetworkHelper.DEFAULT_USER_AGENT)
fun showSeriesInShortcuts() = prefs.getBoolean(Keys.showSeriesInShortcuts, true)
fun showSourcesInShortcuts() = prefs.getBoolean(Keys.showSourcesInShortcuts, true)
fun openChapterInShortcuts() = prefs.getBoolean(Keys.openChapterInShortcuts, true)

View file

@ -0,0 +1,29 @@
package eu.kanade.tachiyomi.source
import android.graphics.drawable.Drawable
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.source.online.HttpSource
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
fun Source.includeLangInName(enabledLanguages: Set<String>, extensionManager: ExtensionManager? = null): Boolean {
val httpSource = this as? HttpSource ?: return true
val extManager = extensionManager ?: Injekt.get()
val allExt = httpSource.getExtension(extManager)?.lang == "all"
val onlyAll = httpSource.extOnlyHasAllLanguage(extManager)
val isMultiLingual = enabledLanguages.filterNot { it == "all" }.size > 1
return (isMultiLingual && allExt) || (lang == "all" && !onlyAll)
}
fun Source.nameBasedOnEnabledLanguages(enabledLanguages: Set<String>, extensionManager: ExtensionManager? = null): String {
return if (includeLangInName(enabledLanguages, extensionManager)) toString() else name
}
fun Source.icon(): Drawable? = Injekt.get<ExtensionManager>().getAppIconForSource(this)
fun HttpSource.getExtension(extensionManager: ExtensionManager? = null): Extension.Installed? =
(extensionManager ?: Injekt.get()).installedExtensionsFlow.value.find { it.sources.contains(this) }
fun HttpSource.extOnlyHasAllLanguage(extensionManager: ExtensionManager? = null) =
getExtension(extensionManager)?.sources?.all { it.lang == "all" } ?: true

View file

@ -1,100 +0,0 @@
package eu.kanade.tachiyomi.source.model
import eu.kanade.tachiyomi.data.database.models.MangaImpl
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
val originalTitle: String
get() = (this as? MangaImpl)?.ogTitle ?: title
val originalAuthor: String?
get() = (this as? MangaImpl)?.ogAuthor ?: author
val originalArtist: String?
get() = (this as? MangaImpl)?.ogArtist ?: artist
val originalDescription: String?
get() = (this as? MangaImpl)?.ogDesc ?: description
val originalGenre: String?
get() = (this as? MangaImpl)?.ogGenre ?: genre
val originalStatus: Int
get() = (this as? MangaImpl)?.ogStatus ?: status
val hasSameAuthorAndArtist: Boolean
get() = author == artist || artist.isNullOrBlank() ||
author?.contains(artist ?: "", true) == true
fun copyFrom(other: SManga) {
if (other.author != null) {
author = other.originalAuthor
}
if (other.artist != null) {
artist = other.originalArtist
}
if (other.description != null) {
description = other.originalDescription
}
if (other.genre != null) {
genre = other.originalGenre
}
if (other.thumbnail_url != null) {
thumbnail_url = other.thumbnail_url
}
status = other.originalStatus
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.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 MangaImpl()
}
}
}

View file

@ -4,6 +4,7 @@ import android.net.Uri
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.toChapter
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.online.DelegatedHttpSource
@ -13,7 +14,7 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.withContext
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.Locale
import java.util.*
class Cubari : DelegatedHttpSource() {
override val domainName: String = "cubari"

View file

@ -4,6 +4,7 @@ import android.net.Uri
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.toChapter
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.await
@ -19,7 +20,7 @@ import okhttp3.CacheControl
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.util.Locale
import java.util.*
class MangaDex : DelegatedHttpSource() {

View file

@ -5,6 +5,7 @@ import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.MangaImpl
import eu.kanade.tachiyomi.data.database.models.toChapter
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST

View file

@ -4,6 +4,7 @@ import android.net.Uri
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.toChapter
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.await

View file

@ -29,6 +29,7 @@ import eu.kanade.tachiyomi.data.preference.PreferenceKeys
import eu.kanade.tachiyomi.data.preference.changesIn
import eu.kanade.tachiyomi.extension.ShizukuInstaller
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.NetworkPreferences
import eu.kanade.tachiyomi.network.PREF_DOH_360
import eu.kanade.tachiyomi.network.PREF_DOH_ADGUARD
import eu.kanade.tachiyomi.network.PREF_DOH_ALIDNS
@ -83,6 +84,7 @@ import java.io.File
class SettingsAdvancedController : SettingsLegacyController() {
private val network: NetworkHelper by injectLazy()
private val networkPreferences: NetworkPreferences by injectLazy()
private val db: DatabaseHelper by injectLazy()
@ -278,7 +280,7 @@ class SettingsAdvancedController : SettingsLegacyController() {
}
}
editTextPreference(activity) {
bindTo(preferences.defaultUserAgent())
bindTo(networkPreferences.defaultUserAgent())
titleRes = R.string.user_agent_string
onChange {

View file

@ -19,12 +19,10 @@ import android.os.PowerManager
import android.provider.Settings
import android.util.TypedValue
import android.view.View
import android.widget.Toast
import androidx.annotation.AttrRes
import androidx.annotation.ColorInt
import androidx.annotation.ColorRes
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatDelegate
import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent
@ -54,26 +52,6 @@ import kotlin.math.max
private const val TABLET_UI_MIN_SCREEN_WIDTH_DP = 720
/**
* 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) {
Toast.makeText(this, resource, duration).show()
}
/**
* 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) {
Toast.makeText(this, text.orEmpty(), duration).show()
}
/**
* Helper method to create a notification.
*

View file

@ -1,3 +1,7 @@
import com.android.build.gradle.BaseExtension
import com.android.build.gradle.BasePlugin
import org.gradle.api.tasks.testing.logging.TestLogEvent
import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
import java.util.*
plugins {
@ -23,6 +27,45 @@ buildscript {
}
}
subprojects {
tasks.withType<KotlinJvmCompile> {
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
}
tasks.withType<Test> {
useJUnitPlatform()
testLogging {
events(TestLogEvent.PASSED, TestLogEvent.SKIPPED, TestLogEvent.FAILED)
}
}
plugins.withType<BasePlugin> {
configure<BaseExtension> {
compileSdkVersion(AndroidConfig.compileSdk)
defaultConfig {
minSdk = AndroidConfig.minSdk
targetSdk = AndroidConfig.targetSdk
ndk {
version = AndroidConfig.ndk
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
isCoreLibraryDesugaringEnabled = true
}
dependencies {
add("coreLibraryDesugaring", libs.desugar)
}
}
}
}
tasks.named("dependencyUpdates", com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask::class.java).configure {
rejectVersionIf {
val stableKeyword = listOf("RELEASE", "FINAL", "GA").any { candidate.version.uppercase(Locale.ROOT).contains(it) }

48
core/build.gradle.kts Normal file
View file

@ -0,0 +1,48 @@
plugins {
kotlin("multiplatform")
kotlin("plugin.serialization")
id("com.android.library")
}
kotlin {
androidTarget()
sourceSets {
val commonMain by getting {
dependencies {
api(libs.okhttp)
api(libs.okhttp.logging.interceptor)
api(libs.okhttp.dnsoverhttps)
api(libs.okhttp.brotli)
api(libs.okio)
api(libs.bundles.logging)
}
}
val androidMain by getting {
dependencies {
api(androidx.core)
api(androidx.annotation)
api(libs.rxjava)
api(project.dependencies.enforcedPlatform(kotlinx.coroutines.bom))
api(kotlinx.coroutines.core)
api(kotlinx.serialization.json)
api(kotlinx.serialization.json.okio)
implementation(libs.quickjs.android)
}
}
}
}
android {
namespace = "yokai.core"
}
tasks {
withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
kotlinOptions.freeCompilerArgs += listOf(
"-Xcontext-receivers",
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=kotlinx.serialization.ExperimentalSerializationApi",
)
}
}

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />

View file

@ -2,6 +2,8 @@ package eu.kanade.tachiyomi.core.preference
import android.content.SharedPreferences
import android.content.SharedPreferences.Editor
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.core.content.edit
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
@ -152,6 +154,7 @@ sealed class AndroidPreference<T>(
}
}
@RequiresApi(Build.VERSION_CODES.HONEYCOMB)
class StringSetPrimitive(
preferences: SharedPreferences,
keyFlow: Flow<String?>,

View file

@ -1,10 +1,6 @@
package eu.kanade.tachiyomi.network
import android.content.Context
import com.chuckerteam.chucker.api.ChuckerCollector
import com.chuckerteam.chucker.api.ChuckerInterceptor
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.network.interceptor.CloudflareInterceptor
import eu.kanade.tachiyomi.network.interceptor.IgnoreGzipInterceptor
import eu.kanade.tachiyomi.network.interceptor.UncaughtExceptionInterceptor
@ -12,13 +8,14 @@ import eu.kanade.tachiyomi.network.interceptor.UserAgentInterceptor
import okhttp3.Cache
import okhttp3.OkHttpClient
import okhttp3.brotli.BrotliInterceptor
import uy.kohesive.injekt.injectLazy
import java.io.File
import java.util.concurrent.TimeUnit
import java.util.concurrent.*
class NetworkHelper(val context: Context) {
private val preferences: PreferencesHelper by injectLazy()
class NetworkHelper(
val context: Context,
private val networkPreferences: NetworkPreferences,
private val block: (OkHttpClient.Builder) -> Unit,
) {
private val cacheDir = File(context.cacheDir, "network_cache")
@ -43,18 +40,9 @@ class NetworkHelper(val context: Context) {
.addNetworkInterceptor(IgnoreGzipInterceptor())
.addNetworkInterceptor(BrotliInterceptor)
.apply {
if (BuildConfig.DEBUG) {
addInterceptor(
ChuckerInterceptor.Builder(context)
.collector(ChuckerCollector(context))
.maxContentLength(250000L)
.redactHeaders(emptySet())
.alwaysReadResponseBody(false)
.build(),
)
}
block(this)
when (preferences.dohProvider()) {
when (networkPreferences.dohProvider().get()) {
PREF_DOH_CLOUDFLARE -> dohCloudflare()
PREF_DOH_GOOGLE -> dohGoogle()
PREF_DOH_ADGUARD -> dohAdGuard()
@ -75,7 +63,7 @@ class NetworkHelper(val context: Context) {
}
val defaultUserAgent
get() = preferences.defaultUserAgent().get().replace("\n", " ").trim()
get() = networkPreferences.defaultUserAgent().get().replace("\n", " ").trim()
companion object {
const val DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/118.0"

View file

@ -0,0 +1,13 @@
package eu.kanade.tachiyomi.network
import android.os.Build
import androidx.annotation.RequiresApi
import eu.kanade.tachiyomi.core.preference.PreferenceStore
class NetworkPreferences(private val preferenceStore: PreferenceStore) {
fun dohProvider() = preferenceStore.getInt("doh_provider", -1)
@RequiresApi(Build.VERSION_CODES.GINGERBREAD)
fun defaultUserAgent() = preferenceStore.getString("default_user_agent", NetworkHelper.DEFAULT_USER_AGENT)
}

View file

@ -16,7 +16,7 @@ import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import java.io.IOException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.*
class CloudflareInterceptor(
private val context: Context,

View file

@ -5,7 +5,7 @@ import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Response
import java.io.IOException
import java.util.concurrent.TimeUnit
import java.util.concurrent.*
/**
* An OkHttp interceptor that handles rate limiting.

View file

@ -6,7 +6,7 @@ import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Response
import java.io.IOException
import java.util.concurrent.TimeUnit
import java.util.concurrent.*
/**
* An OkHttp interceptor that handles given url host's rate limiting.

View file

@ -15,9 +15,8 @@ import okhttp3.Headers
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import java.util.Locale
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.*
import java.util.concurrent.*
abstract class WebViewInterceptor(
private val context: Context,

View file

@ -0,0 +1,25 @@
package eu.kanade.tachiyomi.util.system
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) {
Toast.makeText(this, resource, duration).show()
}
/**
* 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) {
Toast.makeText(this, text.orEmpty(), duration).show()
}

View file

@ -1,17 +1,18 @@
package eu.kanade.tachiyomi.util.system
package yokai.util.lang
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.InternalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import rx.Emitter
import rx.Observable
import rx.Subscriber
import rx.Subscription
import kotlin.coroutines.cancellation.CancellationException
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@ -21,6 +22,7 @@ import kotlin.coroutines.resumeWithException
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(

View file

@ -1,9 +1,5 @@
package eu.kanade.tachiyomi.core.preference
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
@ -77,9 +73,3 @@ fun Preference<Boolean>.toggle(): Boolean {
set(!get())
return get()
}
@Composable
fun <T> Preference<T>.collectAsState(): State<T> {
val flow = remember(this) { changes() }
return flow.collectAsState(initial = get())
}

View file

@ -16,7 +16,7 @@ import rx.Observable
import rx.Producer
import rx.Subscription
import java.io.IOException
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.*
import kotlin.coroutines.resumeWithException
val jsonMime = "application/json; charset=utf-8".toMediaType()

View file

@ -7,7 +7,7 @@ import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.RequestBody
import java.util.concurrent.TimeUnit.MINUTES
import java.util.concurrent.TimeUnit.*
private val DEFAULT_CACHE_CONTROL = CacheControl.Builder().maxAge(10, MINUTES).build()
private val DEFAULT_HEADERS = Headers.Builder().build()

11
domain/build.gradle.kts Normal file
View file

@ -0,0 +1,11 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "yokai.domain"
}
dependencies {
}

View file

@ -0,0 +1,384 @@
package eu.kanade.tachiyomi.data.database.models
import android.content.Context
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.updateStrategyAdapter
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.ui.reader.settings.OrientationType
import eu.kanade.tachiyomi.ui.reader.settings.ReadingModeType
import eu.kanade.tachiyomi.util.manga.MangaCoverMetadata
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.*
interface Manga : SManga {
var id: Long?
var source: Long
var favorite: Boolean
var last_update: Long
var date_added: Long
var viewer_flags: Int
var chapter_flags: Int
var hide_title: Boolean
var filtered_scanlators: String?
fun isBlank() = id == Long.MIN_VALUE
fun isHidden() = status == -1
fun setChapterOrder(sorting: Int, order: Int) {
setChapterFlags(sorting, CHAPTER_SORTING_MASK)
setChapterFlags(order, CHAPTER_SORT_MASK)
setChapterFlags(CHAPTER_SORT_LOCAL, CHAPTER_SORT_LOCAL_MASK)
}
fun setSortToGlobal() = setChapterFlags(CHAPTER_SORT_FILTER_GLOBAL, CHAPTER_SORT_LOCAL_MASK)
fun setFilterToGlobal() = setChapterFlags(CHAPTER_SORT_FILTER_GLOBAL, CHAPTER_FILTER_LOCAL_MASK)
fun setFilterToLocal() = setChapterFlags(CHAPTER_FILTER_LOCAL, CHAPTER_FILTER_LOCAL_MASK)
private fun setChapterFlags(flag: Int, mask: Int) {
chapter_flags = chapter_flags and mask.inv() or (flag and mask)
}
private fun setViewerFlags(flag: Int, mask: Int) {
viewer_flags = viewer_flags and mask.inv() or (flag and mask)
}
val sortDescending: Boolean
get() = chapter_flags and CHAPTER_SORT_MASK == CHAPTER_SORT_DESC
val hideChapterTitles: Boolean
get() = displayMode == CHAPTER_DISPLAY_NUMBER
val usesLocalSort: Boolean
get() = chapter_flags and CHAPTER_SORT_LOCAL_MASK == CHAPTER_SORT_LOCAL
val usesLocalFilter: Boolean
get() = chapter_flags and CHAPTER_FILTER_LOCAL_MASK == CHAPTER_FILTER_LOCAL
fun sortDescending(preferences: PreferencesHelper): Boolean =
if (usesLocalSort) sortDescending else preferences.chaptersDescAsDefault().get()
fun chapterOrder(preferences: PreferencesHelper): Int =
if (usesLocalSort) sorting else preferences.sortChapterOrder().get()
fun readFilter(preferences: PreferencesHelper): Int =
if (usesLocalFilter) readFilter else preferences.filterChapterByRead().get()
fun downloadedFilter(preferences: PreferencesHelper): Int =
if (usesLocalFilter) downloadedFilter else preferences.filterChapterByDownloaded().get()
fun bookmarkedFilter(preferences: PreferencesHelper): Int =
if (usesLocalFilter) bookmarkedFilter else preferences.filterChapterByBookmarked().get()
fun hideChapterTitle(preferences: PreferencesHelper): Boolean =
if (usesLocalFilter) hideChapterTitles else preferences.hideChapterTitlesByDefault().get()
fun showChapterTitle(defaultShow: Boolean): Boolean = chapter_flags and CHAPTER_DISPLAY_MASK == CHAPTER_DISPLAY_NUMBER
fun seriesType(context: Context, sourceManager: SourceManager? = null): String {
return context.getString(
when (seriesType(sourceManager = sourceManager)) {
TYPE_WEBTOON -> R.string.webtoon
TYPE_MANHWA -> R.string.manhwa
TYPE_MANHUA -> R.string.manhua
TYPE_COMIC -> R.string.comic
else -> R.string.manga
},
).lowercase(Locale.getDefault())
}
fun getGenres(): List<String>? {
return genre?.split(",")
?.mapNotNull { tag -> tag.trim().takeUnless { it.isBlank() } }
}
fun getOriginalGenres(): List<String>? {
return (originalGenre ?: genre)?.split(",")
?.mapNotNull { tag -> tag.trim().takeUnless { it.isBlank() } }
}
/**
* The type of comic the manga is (ie. manga, manhwa, manhua)
*/
fun seriesType(useOriginalTags: Boolean = false, customTags: String? = null, sourceManager: SourceManager? = null): Int {
val sourceName by lazy { (sourceManager ?: Injekt.get()).getOrStub(source).name }
val tags = customTags ?: if (useOriginalTags) originalGenre else genre
val currentTags = tags?.split(",")?.map { it.trim().lowercase(Locale.US) } ?: emptyList()
return if (currentTags.any { tag -> isMangaTag(tag) }) {
TYPE_MANGA
} else if (currentTags.any { tag -> isComicTag(tag) } ||
isComicSource(sourceName)
) {
TYPE_COMIC
} else if (currentTags.any { tag -> isWebtoonTag(tag) } ||
(
sourceName.contains("webtoon", true) &&
currentTags.none { tag -> isManhuaTag(tag) } &&
currentTags.none { tag -> isManhwaTag(tag) }
)
) {
TYPE_WEBTOON
} else if (currentTags.any { tag -> isManhuaTag(tag) } || sourceName.contains(
"manhua",
true,
)
) {
TYPE_MANHUA
} else if (currentTags.any { tag -> isManhwaTag(tag) } || isWebtoonSource(sourceName)) {
TYPE_MANHWA
} else {
TYPE_MANGA
}
}
/**
* The type the reader should use. Different from manga type as certain manga has different
* read types
*/
fun defaultReaderType(): Int {
val sourceName = Injekt.get<SourceManager>().getOrStub(source).name
val currentTags = genre?.split(",")?.map { it.trim().lowercase(Locale.US) } ?: emptyList()
return if (currentTags.any
{ tag ->
isManhwaTag(tag) || tag.contains("webtoon")
} || (
isWebtoonSource(sourceName) &&
currentTags.none { tag -> isManhuaTag(tag) } &&
currentTags.none { tag -> isComicTag(tag) }
)
) {
ReadingModeType.LONG_STRIP.flagValue
} else if (currentTags.any
{ tag ->
tag == "chinese" || tag == "manhua" ||
tag.startsWith("english") || tag == "comic"
} || (
isComicSource(sourceName) && !sourceName.contains("tapas", true) &&
currentTags.none { tag -> isMangaTag(tag) }
) ||
(sourceName.contains("manhua", true) && currentTags.none { tag -> isMangaTag(tag) })
) {
ReadingModeType.LEFT_TO_RIGHT.flagValue
} else {
0
}
}
fun isSeriesTag(tag: String): Boolean {
val tagLower = tag.lowercase(Locale.ROOT)
return isMangaTag(tagLower) || isManhuaTag(tagLower) ||
isManhwaTag(tagLower) || isComicTag(tagLower) || isWebtoonTag(tagLower)
}
fun isMangaTag(tag: String): Boolean {
return tag in listOf("manga", "манга", "jp") || tag.startsWith("japanese")
}
fun isManhuaTag(tag: String): Boolean {
return tag in listOf("manhua", "маньхуа", "cn", "hk", "zh-Hans", "zh-Hant") || tag.startsWith("chinese")
}
fun isLongStrip(): Boolean {
val currentTags =
genre?.split(",")?.map { it.trim().lowercase(Locale.US) } ?: emptyList()
return currentTags.any { it == "long strip" }
}
fun isManhwaTag(tag: String): Boolean {
return tag in listOf("long strip", "manhwa", "манхва", "kr") || tag.startsWith("korean")
}
fun isComicTag(tag: String): Boolean {
return tag in listOf("comic", "комикс", "en", "gb") || tag.startsWith("english")
}
fun isWebtoonTag(tag: String): Boolean {
return tag.startsWith("webtoon")
}
fun isWebtoonSource(sourceName: String): Boolean {
return sourceName.contains("webtoon", true) ||
sourceName.contains("manhwa", true) ||
sourceName.contains("toonily", true)
}
fun isComicSource(sourceName: String): Boolean {
return sourceName.contains("gunnerkrigg", true) ||
sourceName.contains("dilbert", true) ||
sourceName.contains("cyanide", true) ||
sourceName.contains("xkcd", true) ||
sourceName.contains("tapas", true) ||
sourceName.contains("ComicExtra", true) ||
sourceName.contains("Read Comics Online", true) ||
sourceName.contains("ReadComicOnline", true)
}
fun isOneShotOrCompleted(db: DatabaseHelper): Boolean {
val tags by lazy { genre?.split(",")?.map { it.trim().lowercase(Locale.US) } }
val chapters by lazy { db.getChapters(this).executeAsBlocking() }
val firstChapterName by lazy { chapters.firstOrNull()?.name?.lowercase() ?: "" }
return status == SManga.COMPLETED || tags?.contains("oneshot") == true ||
(
chapters.size == 1 &&
(
Regex("one.?shot").containsMatchIn(firstChapterName) ||
firstChapterName.contains("oneshot")
)
)
}
fun key(): String {
return "manga-id-$id"
}
// Used to display the chapter's title one way or another
var displayMode: Int
get() = chapter_flags and CHAPTER_DISPLAY_MASK
set(mode) = setChapterFlags(mode, CHAPTER_DISPLAY_MASK)
var readFilter: Int
get() = chapter_flags and CHAPTER_READ_MASK
set(filter) = setChapterFlags(filter, CHAPTER_READ_MASK)
var downloadedFilter: Int
get() = chapter_flags and CHAPTER_DOWNLOADED_MASK
set(filter) = setChapterFlags(filter, CHAPTER_DOWNLOADED_MASK)
var bookmarkedFilter: Int
get() = chapter_flags and CHAPTER_BOOKMARKED_MASK
set(filter) = setChapterFlags(filter, CHAPTER_BOOKMARKED_MASK)
var sorting: Int
get() = chapter_flags and CHAPTER_SORTING_MASK
set(sort) = setChapterFlags(sort, CHAPTER_SORTING_MASK)
var readingModeType: Int
get() = viewer_flags and ReadingModeType.MASK
set(readingMode) = setViewerFlags(readingMode, ReadingModeType.MASK)
var orientationType: Int
get() = viewer_flags and OrientationType.MASK
set(rotationType) = setViewerFlags(rotationType, OrientationType.MASK)
var vibrantCoverColor: Int?
get() = vibrantCoverColorMap[id]
set(value) {
id?.let { vibrantCoverColorMap[it] = value }
}
var dominantCoverColors: Pair<Int, Int>?
get() = MangaCoverMetadata.getColors(this)
set(value) {
value ?: return
MangaCoverMetadata.addCoverColor(this, value.first, value.second)
}
companion object {
// Generic filter that does not filter anything
const val SHOW_ALL = 0x00000000
const val CHAPTER_SORT_DESC = 0x00000000
const val CHAPTER_SORT_ASC = 0x00000001
const val CHAPTER_SORT_MASK = 0x00000001
const val CHAPTER_SORT_FILTER_GLOBAL = 0x00000000
const val CHAPTER_SORT_LOCAL = 0x00001000
const val CHAPTER_SORT_LOCAL_MASK = 0x00001000
const val CHAPTER_FILTER_LOCAL = 0x00002000
const val CHAPTER_FILTER_LOCAL_MASK = 0x00002000
const val CHAPTER_SHOW_UNREAD = 0x00000002
const val CHAPTER_SHOW_READ = 0x00000004
const val CHAPTER_READ_MASK = 0x00000006
const val CHAPTER_SHOW_DOWNLOADED = 0x00000008
const val CHAPTER_SHOW_NOT_DOWNLOADED = 0x00000010
const val CHAPTER_DOWNLOADED_MASK = 0x00000018
const val CHAPTER_SHOW_BOOKMARKED = 0x00000020
const val CHAPTER_SHOW_NOT_BOOKMARKED = 0x00000040
const val CHAPTER_BOOKMARKED_MASK = 0x00000060
const val CHAPTER_SORTING_SOURCE = 0x00000000
const val CHAPTER_SORTING_NUMBER = 0x00000100
const val CHAPTER_SORTING_UPLOAD_DATE = 0x00000200
const val CHAPTER_SORTING_MASK = 0x00000300
const val CHAPTER_DISPLAY_NAME = 0x00000000
const val CHAPTER_DISPLAY_NUMBER = 0x00100000
const val CHAPTER_DISPLAY_MASK = 0x00100000
const val TYPE_MANGA = 1
const val TYPE_MANHWA = 2
const val TYPE_MANHUA = 3
const val TYPE_COMIC = 4
const val TYPE_WEBTOON = 5
private val vibrantCoverColorMap: HashMap<Long, Int?> = hashMapOf()
fun create(source: Long): Manga = MangaImpl().apply {
this.source = source
}
fun create(pathUrl: String, title: String, source: Long = 0): Manga = MangaImpl().apply {
url = pathUrl
this.title = title
this.source = source
}
fun mapper(
id: Long,
source: Long,
url: String,
artist: String?,
author: String?,
description: String?,
genre: String?,
title: String,
status: Long,
thumbnailUrl: String?,
favorite: Long,
lastUpdate: Long?,
initialized: Boolean,
viewerFlags: Long,
hideTitle: Long,
chapterFlags: Long,
dateAdded: Long?,
filteredScanlators: String?,
updateStrategy: Long
): Manga = create(source).apply {
this.id = id
this.url = url
this.artist = artist
this.author = author
this.description = description
this.genre = genre
this.title = title
this.status = status.toInt()
this.thumbnail_url = thumbnailUrl
this.favorite = favorite > 0
this.last_update = lastUpdate ?: 0L
this.initialized = initialized
this.viewer_flags = viewerFlags.toInt()
this.chapter_flags = chapterFlags.toInt()
this.hide_title = hideTitle > 0
this.date_added = dateAdded ?: 0L
this.filtered_scanlators = filteredScanlators
this.update_strategy = updateStrategy.toInt().let(updateStrategyAdapter::decode)
}
}
}

View file

@ -1,13 +1,13 @@
[versions]
kotlin = "1.9.24"
coroutines = "1.8.0"
serialization = "1.6.2"
xml_serialization = "0.86.3"
[libraries]
coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
coroutines-bom = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-bom", version = "1.8.0" }
coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android" }
coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core" }
coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test" }
gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
serialization-gradle = { module = "org.jetbrains.kotlin:kotlin-serialization", version.ref = "kotlin" }
serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
@ -22,6 +22,7 @@ serialization = [
"serialization-gradle", "serialization-json", "serialization-json-okio", "serialization-protobuf",
"serialization-xml", "serialization-xml-core"
]
coroutines = [ "coroutines-android", "coroutines-core" ]
[plugins]
android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }

View file

@ -0,0 +1,17 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "yokai.presentation.core"
defaultConfig {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
}
dependencies {
}

View file

21
presentation-core/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# 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

View file

@ -0,0 +1,31 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "yokai.presentation.widget"
defaultConfig {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = compose.versions.compose.compiler.get()
}
}
dependencies {
implementation(project(":core"))
implementation(project(":domain"))
implementation(project(":presentation-core"))
implementation(androidx.glance.appwidget)
implementation(libs.coil3)
}

View file

21
presentation-widget/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# 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

View file

@ -0,0 +1,14 @@
package yokai.presentation.widget
import android.content.Context
import androidx.glance.appwidget.GlanceAppWidgetManager
class TachiyomiWidgetManager {
suspend fun Context.init() {
val manager = GlanceAppWidgetManager(this)
if (manager.getGlanceIds(UpdatesGridGlanceWidget::class.java).isNotEmpty()) {
UpdatesGridGlanceWidget().loadData()
}
}
}

View file

@ -0,0 +1,8 @@
package yokai.presentation.widget
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
class UpdatesGridGlanceReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = UpdatesGridGlanceWidget().apply { loadData() }
}

View file

@ -0,0 +1,128 @@
package yokai.presentation.widget
import android.app.Application
import android.content.Context
import android.graphics.Bitmap
import android.os.Build
import androidx.core.graphics.drawable.toBitmap
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.ImageProvider
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.appWidgetBackground
import androidx.glance.appwidget.provideContent
import androidx.glance.appwidget.updateAll
import androidx.glance.background
import androidx.glance.layout.fillMaxSize
import coil3.executeBlocking
import coil3.imageLoader
import coil3.request.CachePolicy
import coil3.request.ImageRequest
import coil3.request.transformations
import coil3.size.Precision
import coil3.size.Scale
import coil3.transform.RoundedCornersTransformation
import eu.kanade.tachiyomi.appwidget.components.CoverHeight
import eu.kanade.tachiyomi.appwidget.components.CoverWidth
import eu.kanade.tachiyomi.appwidget.components.LockedWidget
import eu.kanade.tachiyomi.appwidget.components.UpdatesWidget
import eu.kanade.tachiyomi.appwidget.util.appWidgetBackgroundRadius
import eu.kanade.tachiyomi.appwidget.util.calculateRowAndColumnCount
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.ui.recents.RecentsPresenter
import eu.kanade.tachiyomi.util.system.dpToPx
import eu.kanade.tachiyomi.util.system.launchIO
import kotlinx.coroutines.MainScope
import uy.kohesive.injekt.injectLazy
import java.util.*
import kotlin.math.min
class UpdatesGridGlanceWidget : GlanceAppWidget() {
private val app: Application by injectLazy()
private val preferences: PreferencesHelper by injectLazy()
private val coroutineScope = MainScope()
private var data: List<Pair<Long, Bitmap?>>? = null
override val sizeMode = SizeMode.Exact
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
// If app lock enabled, don't do anything
if (preferences.useBiometrics().get()) {
LockedWidget()
} else {
UpdatesWidget(data)
}
}
}
fun loadData(list: List<Pair<Manga, Long>>? = null) {
coroutineScope.launchIO {
// Don't show anything when lock is active
if (preferences.useBiometrics().get()) {
updateAll(app)
return@launchIO
}
val manager = GlanceAppWidgetManager(app)
val ids = manager.getGlanceIds(this@UpdatesGridGlanceWidget::class.java)
if (ids.isEmpty()) return@launchIO
val (rowCount, columnCount) = ids
.flatMap { manager.getAppWidgetSizes(it) }
.maxBy { it.height.value * it.width.value }
.calculateRowAndColumnCount()
val processList = list ?: RecentsPresenter.getRecentManga(customAmount = min(50, rowCount * columnCount))
data = prepareList(processList, rowCount * columnCount)
ids.forEach { update(app, it) }
}
}
private fun prepareList(processList: List<Pair<Manga, Long>>, take: Int): List<Pair<Long, Bitmap?>> {
// Resize to cover size
val widthPx = CoverWidth.value.toInt().dpToPx
val heightPx = CoverHeight.value.toInt().dpToPx
val roundPx = app.resources.getDimension(R.dimen.appwidget_inner_radius)
return processList
// .distinctBy { it.first.id }
.sortedByDescending { it.second }
.take(take)
.map { it.first }
.map { updatesView ->
val request = ImageRequest.Builder(app)
.data(updatesView)
.memoryCachePolicy(CachePolicy.DISABLED)
.precision(Precision.EXACT)
.size(widthPx, heightPx)
.scale(Scale.FILL)
.let {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
it.transformations(RoundedCornersTransformation(roundPx))
} else {
it // Handled by system
}
}
.build()
Pair(updatesView.id!!, app.imageLoader.executeBlocking(request).image?.asDrawable(app.resources)?.toBitmap())
}
}
companion object {
val DateLimit: Calendar
get() = Calendar.getInstance().apply {
time = Date()
add(Calendar.MONTH, -3)
}
}
}
val ContainerModifier = GlanceModifier
.fillMaxSize()
.background(ImageProvider(R.drawable.appwidget_background))
.appWidgetBackground()
.appWidgetBackgroundRadius()

View file

@ -0,0 +1,44 @@
package yokai.presentation.widget.components
import android.content.Intent
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.glance.GlanceModifier
import androidx.glance.LocalContext
import androidx.glance.action.clickable
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.padding
import androidx.glance.text.Text
import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import androidx.glance.unit.ColorProvider
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.appwidget.ContainerModifier
import eu.kanade.tachiyomi.appwidget.util.stringResource
import eu.kanade.tachiyomi.ui.main.MainActivity
@Composable
fun LockedWidget() {
val intent = Intent(LocalContext.current, Class.forName(MainActivity.MAIN_ACTIVITY)).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
Box(
modifier = GlanceModifier
.clickable(actionStartActivity(intent))
.then(ContainerModifier)
.padding(8.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(R.string.appwidget_unavailable_locked),
style = TextStyle(
color = ColorProvider(R.color.appwidget_on_secondary_container),
fontSize = 12.sp,
textAlign = TextAlign.Center,
),
)
}
}

View file

@ -0,0 +1,48 @@
package yokai.presentation.widget.components
import android.graphics.Bitmap
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.glance.GlanceModifier
import androidx.glance.Image
import androidx.glance.ImageProvider
import androidx.glance.layout.Box
import androidx.glance.layout.ContentScale
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.size
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.appwidget.util.appWidgetInnerRadius
val CoverWidth = 58.dp
val CoverHeight = 87.dp
@Composable
fun UpdatesMangaCover(
modifier: GlanceModifier = GlanceModifier,
cover: Bitmap?,
) {
Box(
modifier = modifier
.size(width = CoverWidth, height = CoverHeight)
.appWidgetInnerRadius(),
) {
if (cover != null) {
Image(
provider = ImageProvider(cover),
contentDescription = null,
modifier = GlanceModifier
.fillMaxSize()
.appWidgetInnerRadius(),
contentScale = ContentScale.Crop,
)
} else {
// Enjoy placeholder
Image(
provider = ImageProvider(R.drawable.appwidget_cover_error),
contentDescription = null,
modifier = GlanceModifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
}
}
}

View file

@ -0,0 +1,75 @@
package yokai.presentation.widget.components
import android.content.Intent
import android.graphics.Bitmap
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.glance.GlanceModifier
import androidx.glance.LocalContext
import androidx.glance.LocalSize
import androidx.glance.action.clickable
import androidx.glance.appwidget.CircularProgressIndicator
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.padding
import androidx.glance.text.Text
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.appwidget.ContainerModifier
import eu.kanade.tachiyomi.appwidget.util.calculateRowAndColumnCount
import eu.kanade.tachiyomi.appwidget.util.stringResource
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.main.SearchActivity
@Composable
fun UpdatesWidget(data: List<Pair<Long, Bitmap?>>?) {
val (rowCount, columnCount) = LocalSize.current.calculateRowAndColumnCount()
val mainIntent = Intent(LocalContext.current, MainActivity::class.java).setAction(MainActivity.SHORTCUT_RECENTS)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
Column(
modifier = ContainerModifier.clickable(actionStartActivity(mainIntent)),
verticalAlignment = Alignment.CenterVertically,
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (data == null) {
CircularProgressIndicator()
} else if (data.isEmpty()) {
Text(text = stringResource(R.string.no_recent_read_updated_manga))
} else {
(0 until rowCount).forEach { i ->
val coverRow = (0 until columnCount).mapNotNull { j ->
data.getOrNull(j + (i * columnCount))
}
if (coverRow.isNotEmpty()) {
Row(
modifier = GlanceModifier
.padding(vertical = 4.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.CenterVertically,
) {
coverRow.forEach { (mangaId, cover) ->
Box(
modifier = GlanceModifier
.padding(horizontal = 3.dp),
contentAlignment = Alignment.Center,
) {
val intent = SearchActivity.openMangaIntent(LocalContext.current, mangaId, true)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
// https://issuetracker.google.com/issues/238793260
.addCategory(mangaId.toString())
UpdatesMangaCover(
modifier = GlanceModifier.clickable(actionStartActivity(intent)),
cover = cover,
)
}
}
}
}
}
}
}
}

View file

@ -0,0 +1,43 @@
package yokai.presentation.widget.util
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.DpSize
import androidx.glance.GlanceModifier
import androidx.glance.LocalContext
import androidx.glance.appwidget.cornerRadius
import eu.kanade.tachiyomi.R
fun GlanceModifier.appWidgetBackgroundRadius(): GlanceModifier {
return this.cornerRadius(R.dimen.appwidget_background_radius)
}
fun GlanceModifier.appWidgetInnerRadius(): GlanceModifier {
return this.cornerRadius(R.dimen.appwidget_inner_radius)
}
@Composable
fun stringResource(@StringRes id: Int): String {
return LocalContext.current.getString(id)
}
/**
* Calculates row-column count.
*
* Row
* Numerator: Container height - container vertical padding
* Denominator: Cover height + cover vertical padding
*
* Column
* Numerator: Container width - container horizontal padding
* Denominator: Cover width + cover horizontal padding
*
* @return pair of row and column count
*/
fun DpSize.calculateRowAndColumnCount(): Pair<Int, Int> {
// Hack: Size provided by Glance manager is not reliable so take at least 1 row and 1 column
// Set max to 10 children each direction because of Glance limitation
val rowCount = (height.value / 95).toInt().coerceIn(1, 10)
val columnCount = (width.value / 64).toInt().coerceIn(1, 10)
return Pair(rowCount, columnCount)
}

View file

@ -27,5 +27,12 @@ dependencyResolutionManagement {
}
}
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
rootProject.name = "Yokai"
include(":app")
include(":core")
include(":domain")
include(":presentation-core")
include(":presentation-widget")
include(":source-api")

View file

@ -0,0 +1,42 @@
plugins {
kotlin("multiplatform")
kotlin("plugin.serialization")
id("com.android.library")
}
kotlin {
androidTarget()
sourceSets {
val commonMain by getting {
dependencies {
api(kotlinx.serialization.json)
api(libs.injekt.core)
api(libs.rxjava)
api(libs.jsoup)
}
}
val androidMain by getting {
dependencies {
implementation(projects.core)
api(androidx.preference)
// Workaround for https://youtrack.jetbrains.com/issue/KT-57605
implementation(kotlinx.coroutines.android)
implementation(project.dependencies.platform(kotlinx.coroutines.bom))
}
}
}
}
android {
namespace = "eu.kanade.tachiyomi.source"
defaultConfig {
consumerProguardFile("consumer-proguard.pro")
}
}
tasks {
withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
kotlinOptions.freeCompilerArgs += listOf(
"-Xexpect-actual-classes",
)
}
}

View file

@ -0,0 +1,5 @@
-keep class eu.kanade.tachiyomi.source.model.** { public protected *; }
-keep class eu.kanade.tachiyomi.source.online.** { public protected *; }
-keep class eu.kanade.tachiyomi.source.** extends eu.kanade.tachiyomi.source.Source { public protected *; }
-keep,allowoptimization class eu.kanade.tachiyomi.util.JsoupExtensionsKt { public protected *; }

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />

View file

@ -0,0 +1,6 @@
package eu.kanade.tachiyomi.util
import rx.Observable
import yokai.util.lang.awaitSingle
actual suspend fun <T> Observable<T>.awaitSingle(): T = awaitSingle()

View file

@ -2,7 +2,7 @@ package eu.kanade.tachiyomi.source
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.util.system.awaitSingle
import eu.kanade.tachiyomi.util.awaitSingle
import rx.Observable
interface CatalogueSource : Source {

View file

@ -1,15 +1,10 @@
package eu.kanade.tachiyomi.source
import android.graphics.drawable.Drawable
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.system.awaitSingle
import eu.kanade.tachiyomi.util.awaitSingle
import rx.Observable
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
/**
* A basic interface for creating a source. It could be an online source, a local source, etc.
@ -66,19 +61,6 @@ interface Source {
return fetchPageList(chapter).awaitSingle()
}
fun includeLangInName(enabledLanguages: Set<String>, extensionManager: ExtensionManager? = null): Boolean {
val httpSource = this as? HttpSource ?: return true
val extManager = extensionManager ?: Injekt.get()
val allExt = httpSource.getExtension(extManager)?.lang == "all"
val onlyAll = httpSource.extOnlyHasAllLanguage(extManager)
val isMultiLingual = enabledLanguages.filterNot { it == "all" }.size > 1
return (isMultiLingual && allExt) || (lang == "all" && !onlyAll)
}
fun nameBasedOnEnabledLanguages(enabledLanguages: Set<String>, extensionManager: ExtensionManager? = null): String {
return if (includeLangInName(enabledLanguages, extensionManager)) toString() else name
}
@Deprecated(
"Use the non-RxJava API instead",
ReplaceWith("getMangaDetails"),
@ -101,6 +83,4 @@ interface Source {
throw IllegalStateException("Not used")
}
fun Source.icon(): Drawable? = Injekt.get<ExtensionManager>().getAppIconForSource(this)
fun Source.preferenceKey(): String = "source_$id"

View file

@ -1,6 +1,5 @@
package eu.kanade.tachiyomi.source.model
import eu.kanade.tachiyomi.data.database.models.ChapterImpl
import java.io.Serializable
interface SChapter : Serializable {
@ -23,16 +22,6 @@ interface SChapter : Serializable {
scanlator = other.scanlator
}
fun toChapter(): ChapterImpl {
return ChapterImpl().apply {
name = this@SChapter.name
url = this@SChapter.url
date_upload = this@SChapter.date_upload
chapter_number = this@SChapter.chapter_number
scanlator = this@SChapter.scanlator
}
}
companion object {
fun create(): SChapter {
return SChapterImpl()

View file

@ -0,0 +1,52 @@
package eu.kanade.tachiyomi.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 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.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()
}
}
}

View file

@ -0,0 +1,24 @@
package eu.kanade.tachiyomi.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
}

View file

@ -1,7 +1,5 @@
package eu.kanade.tachiyomi.source.online
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.asObservableSuccess
@ -13,15 +11,12 @@ import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.util.lang.getUrlWithoutDomain
import eu.kanade.tachiyomi.util.system.awaitSingle
import eu.kanade.tachiyomi.util.awaitSingle
import okhttp3.Headers
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import rx.Observable
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.net.URI
import java.net.URISyntaxException
@ -121,12 +116,6 @@ abstract class HttpSource : CatalogueSource {
}
}
fun getExtension(extensionManager: ExtensionManager? = null): Extension.Installed? =
(extensionManager ?: Injekt.get()).installedExtensionsFlow.value.find { it.sources.contains(this) }
fun extOnlyHasAllLanguage(extensionManager: ExtensionManager? = null) =
getExtension(extensionManager)?.sources?.all { it.lang == "all" } ?: true
/**
* Returns the request for the popular manga given the page.
*
@ -509,6 +498,22 @@ abstract class HttpSource : CatalogueSource {
* Returns the list of filters for the source.
*/
override fun getFilterList() = FilterList()
private fun String.getUrlWithoutDomain(): String {
return try {
val uri = URI(this.replace(" ", "%20"))
var out = uri.path
if (uri.query != null) {
out += "?" + uri.query
}
if (uri.fragment != null) {
out += "#" + uri.fragment
}
out
} catch (e: URISyntaxException) {
this
}
}
}
class LicensedMangaChaptersException : Exception("Licensed - No chapters to show")

View file

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

View file

@ -0,0 +1,5 @@
package eu.kanade.tachiyomi.util
import rx.Observable
expect suspend fun <T> Observable<T>.awaitSingle(): T