refactor: Modularize the project (#97)

* refactor: Modularize the project

* chore: Move okhttp stuff back to androidMain

OkHttp decided to cancel multiplatform plan on 5.0

REF: https://square.github.io/okhttp/changelogs/changelog/#version-500-alpha13

* feat: Start using moko for i18n

* fix: Solve some errors

* chore: Remove manga from domain module

We'll do this later

* fix: Duplicate error

* fix: Conflict function name error

* fix: Target SManga

* fix: Breaking changes after the split

* fix: Not enough heap memory

* chore: Update proguard rules

Sorta similar to upstream

* refactor: Fix namespace
This commit is contained in:
Ahmad Ansori Palembani 2024-06-16 09:34:02 +07:00 committed by GitHub
parent 7e7a37bc53
commit 24ce2683d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
109 changed files with 1146 additions and 308 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,10 @@ android {
}
dependencies {
implementation(projects.core)
implementation(projects.i18n)
implementation(projects.sourceApi)
// Compose
implementation(compose.bundles.compose)
debugImplementation(compose.ui.tooling)
@ -280,8 +271,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 +287,6 @@ dependencies {
implementation(kotlinx.immutable)
"coreLibraryDesugaring"(libs.desugar)
// Tests
testImplementation(libs.bundles.test)
testRuntimeOnly(libs.bundles.test.runtime)
@ -306,13 +295,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

@ -1,19 +1,20 @@
-dontobfuscate
-keep class eu.kanade.tachiyomi.source.** { public protected *; } # Avoid access modification
-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 *; }
-keep class kotlin.** { public protected *; }
-keep,allowoptimization class androidx.preference.** { public protected *; }
-keep,allowoptimization class kotlin.** { public protected *; }
-keep,allowoptimization class kotlinx.coroutines.** { public protected *; }
-keep class kotlinx.serialization.** { public protected *; }
-keep class okhttp3.** { public protected *; }
-keep,allowoptimization class kotlinx.serialization.** { public protected *; }
-keep,allowoptimization class kotlin.time.** { public protected *; }
-keep,allowoptimization class okhttp3.** { public protected *; }
-keep,allowoptimization class okio.** { public protected *; }
-keep,allowoptimization class rx.** { public protected *; }
-keep class org.jsoup.** { public protected *; }
-keep,allowoptimization class org.jsoup.** { public protected *; }
-keep,allowoptimization class com.google.gson.** { public protected *; }
-keep,allowoptimization class app.cash.quickjs.** { public protected *; }
-keep,allowoptimization class uy.kohesive.injekt.** { 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

@ -8,7 +8,7 @@ import androidx.compose.ui.res.stringResource
import androidx.core.net.toUri
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.core.preference.collectAsState
import eu.kanade.tachiyomi.core.storage.preference.collectAsState
@Composable
fun storageLocationText(

View file

@ -20,7 +20,7 @@ import dev.yokai.presentation.component.preference.widget.SliderPreferenceWidget
import dev.yokai.presentation.component.preference.widget.SwitchPreferenceWidget
import dev.yokai.presentation.component.preference.widget.TextPreferenceWidget
import dev.yokai.presentation.component.preference.widget.TrackingPreferenceWidget
import eu.kanade.tachiyomi.core.preference.collectAsState
import eu.kanade.tachiyomi.core.storage.preference.collectAsState
import eu.kanade.tachiyomi.data.track.TrackPreferences
import kotlinx.coroutines.launch
import uy.kohesive.injekt.Injekt

View file

@ -4,7 +4,7 @@ import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import dev.yokai.domain.base.BasePreferences
import eu.kanade.tachiyomi.core.preference.collectAsState
import eu.kanade.tachiyomi.core.storage.preference.collectAsState
import eu.kanade.tachiyomi.ui.base.controller.BaseComposeController
import uy.kohesive.injekt.injectLazy

View file

@ -22,14 +22,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.app.ActivityCompat
import com.google.android.material.color.DynamicColors
import dev.yokai.presentation.component.ThemeItem
import dev.yokai.presentation.theme.Size
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.core.preference.collectAsState
import eu.kanade.tachiyomi.core.storage.preference.collectAsState
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.util.system.Themes
import eu.kanade.tachiyomi.util.system.appDelegateNightMode

View file

@ -21,7 +21,7 @@ import dev.yokai.presentation.component.Gap
import dev.yokai.presentation.component.preference.Preference
import dev.yokai.presentation.component.preference.PreferenceItem
import dev.yokai.presentation.component.preference.widget.PreferenceGroupHeader
import eu.kanade.tachiyomi.core.preference.collectAsState
import eu.kanade.tachiyomi.core.storage.preference.collectAsState
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.util.compose.LocalAlertDialog
import eu.kanade.tachiyomi.util.compose.LocalBackPress

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,55 @@ 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: SManga) {
if (other is 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

@ -0,0 +1,7 @@
package eu.kanade.tachiyomi.source.models
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.source.model.SManga
val SManga.originalTitle: String
get() = if (this is Manga) this.originalTitle else title

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

@ -36,6 +36,7 @@ import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.SourceNotFoundException
import eu.kanade.tachiyomi.source.getExtension
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.base.presenter.BaseCoroutinePresenter
@ -77,8 +78,7 @@ import uy.kohesive.injekt.injectLazy
import java.io.File
import java.io.FileOutputStream
import java.io.OutputStream
import java.util.Date
import java.util.Locale
import java.util.*
class MangaDetailsPresenter(
val manga: Manga,

View file

@ -26,17 +26,18 @@ import androidx.core.widget.TextViewCompat
import androidx.transition.TransitionSet
import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
import coil3.request.CachePolicy
import coil3.request.placeholder
import coil3.request.error
import coil3.request.placeholder
import com.google.android.material.button.MaterialButton
import com.google.android.material.chip.Chip
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.coil.loadManga
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.databinding.ChapterHeaderItemBinding
import eu.kanade.tachiyomi.databinding.MangaHeaderItemBinding
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.nameBasedOnEnabledLanguages
import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder
import eu.kanade.tachiyomi.util.isLocal
import eu.kanade.tachiyomi.util.lang.toNormalized

View file

@ -4,6 +4,7 @@ import android.graphics.Paint.STRIKE_THRU_TEXT_FLAG
import android.view.View
import eu.kanade.tachiyomi.databinding.MigrationSourceItemBinding
import eu.kanade.tachiyomi.source.icon
import eu.kanade.tachiyomi.source.nameBasedOnEnabledLanguages
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder

View file

@ -32,6 +32,7 @@ import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.databinding.StatsDetailsChartBinding
import eu.kanade.tachiyomi.databinding.StatsDetailsControllerBinding
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.nameBasedOnEnabledLanguages
import eu.kanade.tachiyomi.ui.base.SmallToolbarInterface
import eu.kanade.tachiyomi.ui.base.controller.BaseCoroutineController
import eu.kanade.tachiyomi.ui.library.FilteredLibraryController
@ -59,8 +60,7 @@ import eu.kanade.tachiyomi.util.view.setOnQueryTextChangeListener
import eu.kanade.tachiyomi.util.view.withFadeTransaction
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import java.util.Calendar
import java.util.Locale
import java.util.*
class StatsDetailsController :
BaseCoroutineController<StatsDetailsControllerBinding, StatsDetailsPresenter>(),

View file

@ -19,6 +19,7 @@ import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.icon
import eu.kanade.tachiyomi.source.nameBasedOnEnabledLanguages
import eu.kanade.tachiyomi.ui.base.presenter.BaseCoroutinePresenter
import eu.kanade.tachiyomi.ui.more.stats.StatsHelper
import eu.kanade.tachiyomi.util.isLocal
@ -32,9 +33,8 @@ import kotlinx.coroutines.runBlocking
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.util.Calendar
import java.util.Locale
import java.util.concurrent.TimeUnit
import java.util.*
import java.util.concurrent.*
import kotlin.math.roundToInt
class StatsDetailsPresenter(

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

@ -7,6 +7,7 @@ import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.databinding.SourceItemBinding
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.icon
import eu.kanade.tachiyomi.source.includeLangInName
import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder
import eu.kanade.tachiyomi.util.system.getResourceColor
import eu.kanade.tachiyomi.util.view.compatToolTipText

View file

@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.util.chapter
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.models.originalTitle
/**
* -R> = regex conversion.

View file

@ -17,14 +17,11 @@ import android.net.wifi.WifiManager
import android.os.Build
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 +51,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.
*
@ -125,30 +102,6 @@ fun Context.contextCompatDrawable(@DrawableRes resource: Int): Drawable? {
return ContextCompat.getDrawable(this, resource)
}
/**
* Converts to dp.
*/
val Int.pxToDp: Int
get() = (this / Resources.getSystem().displayMetrics.density).toInt()
val Float.pxToDp: Float
get() = (this / Resources.getSystem().displayMetrics.density)
/**
* Converts to px.
*/
val Int.dpToPx: Int
get() = this.toFloat().dpToPx.toInt()
val Int.spToPx: Int
get() = this.toFloat().spToPx.toInt()
val Float.spToPx: Float
get() = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, this, Resources.getSystem().displayMetrics)
val Float.dpToPx: Float
get() = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, this, Resources.getSystem().displayMetrics)
/** Converts to px and takes into account LTR/RTL layout */
fun Float.dpToPxEnd(resources: Resources): Float {
return this * resources.displayMetrics.density * if (resources.isLTR) 1 else -1

View file

@ -1116,12 +1116,6 @@
<string name="file_picker_error">No file picker app found</string>
<string name="file_picker_uri_permission_unsupported">Failed to acquire persistent folder access. The app may behave unexpectedly.</string>
<!-- Webview -->
<string name="failed_to_bypass_cloudflare">Failed to bypass Cloudflare</string>
<string name="please_update_webview">Please update the WebView app for better compatibility</string>
<!-- Do not translate "WebView" -->
<string name="webview_is_required">WebView is required for Tachiyomi</string>
<!-- App widget -->
<string name="appwidget_updates_description">See your recently updated library entries</string>
<string name="appwidget_unavailable_locked">Widget not available when app lock is enabled</string>

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 {
@ -15,6 +19,7 @@ buildscript {
classpath(kotlinx.serialization.gradle)
classpath(libs.firebase.crashlytics.gradle)
classpath(libs.sqldelight.gradle)
classpath(libs.moko.generator)
}
repositories {
gradlePluginPortal()
@ -23,6 +28,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) }

View file

@ -0,0 +1,41 @@
import org.gradle.api.Project
import org.gradle.api.Task
import org.gradle.api.tasks.TaskProvider
import org.gradle.kotlin.dsl.TaskContainerScope
fun TaskContainerScope.registerLocalesConfigTask(project: Project): TaskProvider<Task> {
return with(project) {
register("generateLocalesConfig") {
val emptyResourcesElement = "<resources>\\s*</resources>|<resources/>".toRegex()
val valuesPrefix = "values-?".toRegex()
val languages = fileTree("$projectDir/src/main/res/")
.matching {
include("**/strings.xml")
}
.filterNot {
it.readText().contains(emptyResourcesElement)
}
.map { it.parentFile.name }
.sorted()
.joinToString(separator = "\n") {
val language = it
.replace(valuesPrefix, "")
.replace("-r", "-")
.takeIf(String::isNotBlank) ?: "en"
" <locale android:name=\"$language\"/>"
}
val content = """
<?xml version="1.0" encoding="utf-8"?>
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
$languages
</locale-config>
""".trimIndent()
val localeFile = file("$projectDir/src/main/res/xml/locales_config.xml")
localeFile.parentFile.mkdirs()
localeFile.writeText(content)
}
}
}

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

@ -0,0 +1,49 @@
plugins {
kotlin("multiplatform")
kotlin("plugin.serialization")
id("com.android.library")
}
kotlin {
androidTarget()
sourceSets {
val commonMain by getting {
dependencies {
implementation(projects.i18n)
api(libs.bundles.logging)
}
}
val androidMain by getting {
dependencies {
api(libs.okhttp)
api(libs.okhttp.logging.interceptor)
api(libs.okhttp.dnsoverhttps)
api(libs.okhttp.brotli)
api(libs.okio)
api(androidx.preference)
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

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

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

View file

@ -5,7 +5,6 @@ import android.content.Context
import android.webkit.WebView
import android.widget.Toast
import androidx.core.content.ContextCompat
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.network.AndroidCookieJar
import eu.kanade.tachiyomi.util.system.WebViewClientCompat
import eu.kanade.tachiyomi.util.system.isOutdated
@ -15,8 +14,10 @@ import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import yokai.i18n.MR
import yokai.util.lang.getMString
import java.io.IOException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.*
class CloudflareInterceptor(
private val context: Context,
@ -48,7 +49,7 @@ class CloudflareInterceptor(
// Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that
// we don't crash the entire app
catch (e: CloudflareBypassException) {
throw IOException(context.getString(R.string.failed_to_bypass_cloudflare))
throw IOException(context.getMString(MR.strings.failed_to_bypass_cloudflare))
} catch (e: Exception) {
throw IOException(e)
}
@ -130,7 +131,7 @@ class CloudflareInterceptor(
if (!cloudflareBypassed) {
// Prompt user to update WebView if it seems too outdated
if (isWebViewOutdated) {
context.toast(R.string.please_update_webview, Toast.LENGTH_LONG)
context.toast(MR.strings.please_update_webview, Toast.LENGTH_LONG)
}
throw CloudflareBypassException()

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

@ -5,7 +5,6 @@ import android.os.Build
import android.webkit.WebSettings
import android.webkit.WebView
import android.widget.Toast
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.system.DeviceUtil
import eu.kanade.tachiyomi.util.system.WebViewUtil
import eu.kanade.tachiyomi.util.system.launchUI
@ -15,9 +14,9 @@ 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 yokai.i18n.MR
import java.util.*
import java.util.concurrent.*
abstract class WebViewInterceptor(
private val context: Context,
@ -57,7 +56,7 @@ abstract class WebViewInterceptor(
if (!WebViewUtil.supportsWebView(context)) {
launchUI {
context.toast(R.string.webview_is_required, Toast.LENGTH_LONG)
context.toast(MR.strings.webview_is_required, Toast.LENGTH_LONG)
}
return response
}

View file

@ -0,0 +1,28 @@
package eu.kanade.tachiyomi.util.system
import android.content.res.Resources
import android.util.TypedValue
/**
* Converts to dp.
*/
val Int.pxToDp: Int
get() = (this / Resources.getSystem().displayMetrics.density).toInt()
val Float.pxToDp: Float
get() = (this / Resources.getSystem().displayMetrics.density)
/**
* Converts to px.
*/
val Int.dpToPx: Int
get() = this.toFloat().dpToPx.toInt()
val Int.spToPx: Int
get() = this.toFloat().spToPx.toInt()
val Float.spToPx: Float
get() = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, this, Resources.getSystem().displayMetrics)
val Float.dpToPx: Float
get() = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, this, Resources.getSystem().displayMetrics)

View file

@ -0,0 +1,37 @@
package eu.kanade.tachiyomi.util.system
import android.content.Context
import android.widget.Toast
import androidx.annotation.StringRes
import dev.icerock.moko.resources.StringResource
import yokai.util.lang.getMString
/**
* 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 resource the text resource.
* @param duration the duration of the toast. Defaults to short.
*/
fun Context.toast(resource: StringResource, duration: Int = Toast.LENGTH_SHORT) {
toast(getMString(resource), duration)
}
/**
* 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

@ -0,0 +1,8 @@
package yokai.util.lang
import android.content.Context
import dev.icerock.moko.resources.StringResource
import dev.icerock.moko.resources.desc.Resource
import dev.icerock.moko.resources.desc.StringDesc
fun Context.getMString(stringRes: StringResource): String = StringDesc.Resource(stringRes).toString(this)

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

@ -0,0 +1,10 @@
package eu.kanade.tachiyomi.network
import eu.kanade.tachiyomi.core.preference.PreferenceStore
class NetworkPreferences(private val preferenceStore: PreferenceStore) {
fun dohProvider() = preferenceStore.getInt("doh_provider", -1)
fun defaultUserAgent() = preferenceStore.getString("default_user_agent", NetworkHelper.DEFAULT_USER_AGENT)
}

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

@ -20,7 +20,7 @@
# AndroidX support
android.enableJetifier=true
android.useAndroidX=true
org.gradle.jvmargs=-Xmx2048M
org.gradle.jvmargs=-Xmx4G
org.gradle.caching=true
android.nonTransitiveRClass=false
android.nonFinalResIds=false

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

@ -3,6 +3,7 @@ chucker = "3.5.2"
coil3 = "3.0.0-alpha06"
flexible-adapter = "c8013533"
fast_adapter = "5.6.0"
moko = "0.24.0"
nucleus = "3.0.0"
okhttp = "5.0.0-alpha.14"
shizuku = "12.1.0"
@ -59,6 +60,10 @@ junit-android = { module = "androidx.test.ext:junit", version = "1.1.5" }
junrar = { module = "com.github.junrar:junrar", version = "7.5.5" }
loading-button = { module = "br.com.simplepass:loading-button-android", version = "2.2.0" }
mockk = { module = "io.mockk:mockk", version = "1.13.11" }
moko-resources = { module = "dev.icerock.moko:resources", version.ref = "moko" }
moko-generator = { module = "dev.icerock.moko:resources-generator", version.ref = "moko" }
okio = { module = "com.squareup.okio:okio", version = "3.9.0" }
okhttp-brotli = { module = "com.squareup.okhttp3:okhttp-brotli", version.ref = "okhttp" }
okhttp-dnsoverhttps = { module = "com.squareup.okhttp3:okhttp-dnsoverhttps", version.ref = "okhttp" }

2
i18n/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
# Generated
locales_config.xml

43
i18n/build.gradle.kts Normal file
View file

@ -0,0 +1,43 @@
plugins {
kotlin("multiplatform")
id("com.android.library")
id("dev.icerock.mobile.multiplatform-resources")
}
kotlin {
androidTarget()
applyDefaultHierarchyTemplate()
sourceSets {
val commonMain by getting {
dependencies {
api(libs.moko.resources)
}
}
val androidMain by getting {
dependsOn(commonMain)
}
}
}
android {
namespace = "yokai.i18n"
}
multiplatformResources {
resourcesPackage.set("yokai.i18n")
}
tasks {
val localesConfigTask = registerLocalesConfigTask(project)
preBuild {
dependsOn(localesConfigTask)
}
withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
kotlinOptions.freeCompilerArgs += listOf(
"-Xexpect-actual-classes",
)
}
}

View file

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

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- WebView -->
<string name="failed_to_bypass_cloudflare">Failed to bypass Cloudflare</string>
<!-- Do not translate "WebView" -->
<string name="please_update_webview">Please update the WebView app for better compatibility</string>
<string name="webview_is_required">WebView is required for Tachiyomi</string>
</resources>

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(projects.core)
implementation(projects.domain)
implementation(projects.presentationCore)
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,15 @@
package eu.kanade.tachiyomi.appwidget
import android.content.Context
import androidx.glance.appwidget.GlanceAppWidgetManager
import eu.kanade.tachiyomi.appwidget.UpdatesGridGlanceWidget
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 eu.kanade.tachiyomi.appwidget
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 eu.kanade.tachiyomi.appwidget
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 eu.kanade.tachiyomi.appwidget.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 eu.kanade.tachiyomi.appwidget.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,74 @@
package eu.kanade.tachiyomi.appwidget.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.appwidget.ContainerModifier
import eu.kanade.tachiyomi.appwidget.util.calculateRowAndColumnCount
import eu.kanade.tachiyomi.appwidget.util.stringResource
import eu.kanade.tachiyomi.ui.main.SearchActivity
@Composable
fun UpdatesWidget(data: List<Pair<Long, Bitmap?>>?) {
val (rowCount, columnCount) = LocalSize.current.calculateRowAndColumnCount()
val clazz = Class.forName("eu.kanade.tachiyomi.ui.main.MainActivity")
val mainIntent = Intent(LocalContext.current, clazz).setAction("eu.kanade.tachiyomi.SHOW_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 eu.kanade.tachiyomi.appwidget.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,13 @@ dependencyResolutionManagement {
}
}
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
rootProject.name = "Yokai"
include(":app")
include(":core")
include(":domain")
include(":i18n")
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"

Some files were not shown because too many files have changed in this diff Show more