Files
mihon/app/build.gradle.kts
T
MajorTanyaandMitchell Syer ec614bb4c3 Use Apollo GraphQL for Trackers with GraphQL APIs (#3827)
* Add Apollo for GraphQL interactions

* Migrate AniList to use Apollo

* Migrate Shikimori to use Apollo

Not all Shikimori requests can be served by their GraphQL API (notably
they do not offer any mutations), so those remain as-is.

* Migrate Suwayomi to use Apollo

Includes leveraging some slightly newer Suwayomi features,
specifically the Double/Float filter fixes first pre-released in
2.1.1985 on 2025-10-26 and first fully released in 2.1.2100 on
2026-05-08.

Considering the tachiyomix 1.6 extension lib changes were added in
Suwayomi 2.3.2223 on 2026-06-30, I think it is fair to depend on such
sorta-recent behaviour.

* Fix temp migration names and leftovers

* Create ApolloResponse.dataOrElse extension

Because Apollo doesn't throw exceptions and instead just provides them
as response attributes, the current ApolloResponse.awaitSuccess() is
useless and will be removed shortly.

This new extension function should wrap the basic handling of an
ApolloResponse object neatly.

Co-authored-by: Mitchell Syer <6090655+Syer10@users.noreply.github.com>

* Use dataOrElse in AnilistApi.kt

* Use dataOrElse in ShikimoriApi.kt

* Use dataOrElse in SuwayomiApi.kt

* Delete obsolete ApolloCall.awaitSuccess

* Migrate Kitsu to use Apollo

Kitsu has some unfortunate edges in its GraphQL API (such as returning
nonstandard bodies which don't play well with Apollo) but this should
be an equivalent implementation.

* Extract a MangaFragment for AniList

This simplifies the queries and the converters (which can now delegate
to the MangaFragment.toTrackSearch() method internally and adjust the
track as needed).

Also move the toALUser converter to AnilistUtils.kt to keep all
converters together.

Added a scalar-mapper for AL's CountryCode type.

* Minor consistency cleanup of the queries

* Store list entry ID not title ID for Shikimori

* Extract common MangaFragment for Shikimori

Also map the ISO8601Date to a Kotlin String in the service config.

* Map Kitsu's ISO8601DateTime to kotlin.time.Instant

Utilises Apollo's own adapter-core library which provides the mapper.

Requires opting in to ExperimentalTime until the library resolves the
outdated annotation.

Co-authored-by: Mitchell Syer <6090655+Syer10@users.noreply.github.com>

* Replace Kitsu TODO comment

So much for STOPSHIP preventing me shipping this...

* Bump Apollo version to 5.1.0

* Bump apollo-adapters-core to 0.8.0

Freshly released just earlier.
This resolves having to opt in to ExperimentalTime.

* Make new utils internal to align with #3908

* Fix leftover issues from merged #3828

This PR restructured the AniList and Kitsu user objects.

* Bump apollo to 5.2.0

* Remove unused attributes from queries

* Allow custom exception handler in dataOrElse

Enables callers to manually handle specific non-200 responses.

Callers MUST close the `ApolloHttpException.body` attached to the
passed exception to avoid resource and socket leaks.

Defaults to current behaviour of (re)throwing the status code with our
HttpException.

Utilized in KitsuApi.kt to specially handle Kitsu sending a 500 error
response when deleting library IDs not actually in a user's library
and falling back to the "throw as HttpException" if it's any other
error response.

* Rethrow networking/IO exceptions

This helps to avoid masking networking problems as "This isn't in your
list" problems.

Currently, the app shows "Failed to connect to ..." when the remote
server is unreachable (e.g. downtime, DNS block, etc) because that's
the message provided by the ConnectException thrown by Android's
network stack.

This change restores that behaviour, approximately.

Not the most elegant solution but it _should_ avoid false-positive
toasts.

---------

Co-authored-by: Mitchell Syer <6090655+Syer10@users.noreply.github.com>
2026-09-28 10:34:26 +06:00

452 lines
14 KiB
Kotlin

import com.android.build.api.variant.ApplicationVariant
import com.android.build.api.variant.BuildConfigField
import com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsExtension
import mihon.gradle.Config
import mihon.gradle.getCurrentTime
import mihon.gradle.getLatestCommitCount
import mihon.gradle.getLatestCommitSha
import mihon.gradle.getLatestCommitTime
import mihon.gradle.tasks.ReplaceShortcutsPlaceholderTask
import java.io.FileInputStream
import java.util.Properties
plugins {
alias(mihonx.plugins.android.application)
alias(mihonx.plugins.compose)
alias(mihonx.plugins.spotless)
alias(libs.plugins.metro)
alias(libs.plugins.aboutLibraries)
alias(libs.plugins.androidx.baselineProfile)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.apollo)
}
if (Config.includeTelemetry) {
pluginManager.apply {
apply(libs.plugins.google.services.get().pluginId)
apply(libs.plugins.firebase.crashlytics.get().pluginId)
}
}
val keystorePropertiesFile = layout.settingsDirectory.file("keystore.properties").asFile
android {
namespace = "eu.kanade.tachiyomi"
defaultConfig {
applicationId = "app.mihon"
versionCode = 32
versionName = "0.20.4"
buildConfigField("boolean", "TELEMETRY_INCLUDED", "${Config.includeTelemetry}")
buildConfigField("boolean", "UPDATER_ENABLED", "${Config.enableUpdater}")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
if (System.getenv("MIHON_GITHUB_RELEASE").toBoolean()) {
signingConfigs {
named("debug") {
storeFile = file(System.getenv("storeFile"))
storePassword = System.getenv("storePassword")
keyAlias = System.getenv("keyAlias")
keyPassword = System.getenv("keyPassword")
}
}
} else if (keystorePropertiesFile.exists()) {
val keystoreProperties = FileInputStream(keystorePropertiesFile).use { Properties().apply { load(it) } }
signingConfigs {
named("debug") {
storeFile = file(keystoreProperties.getProperty("storeFile"))
storePassword = keystoreProperties.getProperty("storePassword")
keyAlias = keystoreProperties.getProperty("keyAlias")
keyPassword = keystoreProperties.getProperty("keyPassword")
}
}
}
buildTypes {
val debug = getByName("debug") {
applicationIdSuffix = ".dev"
isPseudoLocalesEnabled = true
}
val release = getByName("release") {
isMinifyEnabled = true
isShrinkResources = true
signingConfig = debug.signingConfig
isProfileable = true
proguardFiles("proguard-android-optimize.txt", "proguard-rules.pro")
}
val commonMatchingFallbacks = listOf(release.name)
create("foss") {
initWith(release)
applicationIdSuffix = ".foss"
matchingFallbacks.addAll(commonMatchingFallbacks)
}
create("nightly") {
initWith(release)
applicationIdSuffix = ".debug"
matchingFallbacks.addAll(commonMatchingFallbacks)
}
create("benchmark") {
initWith(release)
versionNameSuffix = "-benchmark"
applicationIdSuffix = ".benchmark"
matchingFallbacks.addAll(commonMatchingFallbacks)
}
if (Config.includeTelemetry) {
configureEach {
configure<CrashlyticsExtension> {
mappingFileUploadEnabled = Config.uploadCrashlyticsMapping
}
}
}
}
sourceSets {
getByName("nightly").res.directories.add("src/debug/res")
getByName("benchmark").res.directories.add("src/debug/res")
}
splits {
abi {
isEnable = true
isUniversalApk = true
reset()
include("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
}
}
packaging {
jniLibs {
keepDebugSymbols += listOf(
"libandroidx.graphics.path",
"libarchive-jni",
"libconscrypt_jni",
"libimagedecoder",
"libquickjs",
"libsqlite3x",
)
.map { "**/$it.so" }
}
resources {
excludes += setOf(
"kotlin-tooling-metadata.json",
"LICENSE.txt",
"META-INF/**/*.properties",
"META-INF/**/LICENSE.txt",
"META-INF/*.properties",
"META-INF/*.version",
"META-INF/DEPENDENCIES",
"META-INF/LICENSE",
"META-INF/NOTICE",
"META-INF/README.md",
)
}
}
dependenciesInfo {
includeInApk = Config.includeDependencyInfo
includeInBundle = Config.includeDependencyInfo
}
buildFeatures {
viewBinding = true
buildConfig = true
aidl = true
}
lint {
abortOnError = false
checkReleaseBuilds = false
}
}
kotlin {
compilerOptions {
freeCompilerArgs.addAll(
"-opt-in=androidx.compose.animation.ExperimentalAnimationApi",
"-opt-in=androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi",
"-opt-in=androidx.compose.foundation.ExperimentalFoundationApi",
"-opt-in=androidx.compose.foundation.layout.ExperimentalLayoutApi",
"-opt-in=androidx.compose.material3.ExperimentalMaterial3Api",
"-opt-in=androidx.compose.material3.ExperimentalMaterial3ExpressiveApi",
"-opt-in=androidx.compose.ui.ExperimentalComposeUiApi",
"-opt-in=coil3.annotation.ExperimentalCoilApi",
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=kotlinx.coroutines.FlowPreview",
"-opt-in=kotlinx.coroutines.InternalCoroutinesApi",
"-opt-in=kotlinx.serialization.ExperimentalSerializationApi",
)
}
}
baselineProfile {
baselineProfileOutputDir = "baselineProfiles"
mergeIntoMain = true
}
dependencies {
baselineProfile(projects.baselineProfile)
implementation(projects.i18n)
implementation(projects.icons.materialSymbols)
implementation(projects.icons.simpleIcons)
implementation(projects.core.archive)
implementation(projects.core.common)
implementation(projects.core.metro)
implementation(projects.coreMetadata)
implementation(projects.sourceApi)
implementation(projects.sourceLocal)
implementation(projects.data)
implementation(projects.domain)
implementation(projects.presentationCore)
implementation(projects.presentationWidget)
implementation(projects.telemetry)
// Compose
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.foundation)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.animation)
implementation(libs.androidx.compose.animationGraphics)
debugImplementation(libs.androidx.compose.uiTooling)
implementation(libs.androidx.compose.uiToolingPreview)
implementation(libs.androidx.compose.uiUtil)
implementation(libs.androidx.interpolator)
implementation(libs.androidx.paging.runtime)
implementation(libs.androidx.paging.compose)
implementation(libs.kotlin.reflect)
implementation(libs.bundles.kotlinx.coroutines)
implementation(libs.kotlinx.datetime)
// AndroidX libraries
implementation(libs.androidx.annotation)
implementation(libs.androidx.appCompat)
implementation(libs.androidx.biometric)
implementation(libs.androidx.constraintLayout)
implementation(libs.androidx.core)
implementation(libs.androidx.coreSplashScreen)
implementation(libs.androidx.recyclerView)
implementation(libs.androidx.viewPager)
implementation(libs.androidx.profileInstaller)
implementation(libs.bundles.androidx.lifecycle)
// Job scheduling
implementation(libs.androidx.work)
// RxJava
implementation(libs.rxJava)
// Networking
implementation(libs.bundles.okhttp)
implementation(libs.okio)
implementation(libs.conscrypt) // TLS 1.3 support for Android < 10
// Data serialization (JSON, protobuf, xml)
implementation(libs.bundles.serialization)
// HTML parser
implementation(libs.jsoup)
// Disk
implementation(libs.diskLruCache)
implementation(libs.unifile)
// Preferences
implementation(libs.androidx.preference)
// Dependency injection
implementation(libs.injekt)
implementation(libs.metro.runtime)
implementation(libs.metrox.viewmodel)
implementation(libs.metrox.viewmodel.compose)
// Image loading
implementation(libs.bundles.coil)
implementation(libs.subsamplingScaleImageView) {
exclude(module = "image-decoder")
}
implementation(libs.image.decoder)
implementation(libs.webgpuviewer)
// UI libraries
implementation(libs.material)
implementation(libs.flexibleAdapter)
implementation(libs.photoView)
implementation(libs.directionalViewPager) {
exclude(group = "androidx.viewpager", module = "viewpager")
}
implementation(libs.composeRichEditor)
implementation(libs.aboutLibraries.compose)
implementation(libs.bundles.voyager)
implementation(libs.composeMaterialMotion)
implementation(libs.swipe)
implementation(libs.composeWebview)
implementation(libs.composeGrid)
implementation(libs.reorderable)
implementation(libs.bundles.markdown)
implementation(libs.materialKolor)
// Logging
implementation(libs.logcat)
// Shizuku
implementation(libs.bundles.shizuku)
// String similarity
implementation(libs.stringSimilarity)
// GraphQL generation
implementation(libs.apollo)
implementation(libs.apollo.adapters)
// Tests
testImplementation(libs.bundles.test)
testRuntimeOnly(libs.junit.platform.launcher)
// For detecting memory leaks; see https://square.github.io/leakcanary/
// debugImplementation(libs.leakCanary.android)
implementation(libs.leakCanary.plumber)
testImplementation(libs.kotlinx.coroutines.test)
}
apollo {
val schemaBasePath = "src/main/graphql/mihon/graphql"
service("anilist") {
packageName.set("mihon.graphql.anilist")
val srcDir = "$schemaBasePath/anilist"
srcDir(file(srcDir))
introspection {
endpointUrl.set("https://graphql.anilist.co")
schemaFile.set(file("$srcDir/anilist.graphqls"))
}
// ISO 3166-1 alpha-2 country code
mapScalarToKotlinString("CountryCode")
}
service("kitsu") {
packageName.set("mihon.graphql.kitsu")
val srcDir = "$schemaBasePath/kitsu"
srcDir(file(srcDir))
android.defaultConfig.versionCode
introspection {
// Kitsu doesn't like requests without a UA
headers.put(
"User-Agent",
"Mihon v${android.defaultConfig.versionName} (${android.defaultConfig.applicationId})",
)
endpointUrl.set("https://kitsu.app/api/graphql")
schemaFile.set(file("$srcDir/kitsu.graphqls"))
}
// A date, expressed as an ISO8601 string
mapScalarToKotlinString("Date")
// An ISO 8601-encoded datetime
mapScalar("ISO8601DateTime", "kotlin.time.Instant", "com.apollographql.adapter.core.KotlinInstantAdapter")
// A loose key-value map in GraphQL
mapScalar(
"Map",
"kotlin.collections.Map<String, Any?>",
"mihon.graphql.kitsu.adapter.KitsuMapAdapter",
)
}
service("shikimori") {
packageName.set("mihon.graphql.shikimori")
val srcDir = "$schemaBasePath/shikimori"
srcDir(file(srcDir))
introspection {
endpointUrl.set("https://shikimori.io/api/graphql")
schemaFile.set(file("$srcDir/shikimori.graphqls"))
}
// An ISO 8601-encoded date
mapScalarToKotlinString("ISO8601Date")
}
service("suwayomi") {
packageName.set("mihon.graphql.suwayomi")
val srcDir = "$schemaBasePath/suwayomi"
srcDir(file(srcDir))
introspection {
endpointUrl.set("http://localhost:4567/api/graphql")
schemaFile.set(file("$srcDir/suwayomi.graphqls"))
}
}
}
val latestCommitCount = getLatestCommitCount()
val latestCommitSha = getLatestCommitSha()
val latestCommitTime = getLatestCommitTime()
val currentTime = getCurrentTime()
fun ApplicationVariant.buildConfigField(type: String, name: String, value: Provider<String>) {
buildConfigFields?.put(name, value.map { BuildConfigField(type, it, null) })
}
androidComponents {
onVariants { variant ->
val isUnstableBuild = variant.buildType == "debug" || variant.buildType == "nightly"
val buildTime = if (isUnstableBuild) currentTime else latestCommitTime
variant.buildConfigField("String", "COMMIT_COUNT", latestCommitCount.map { "\"$it\"" })
variant.buildConfigField("String", "COMMIT_SHA", latestCommitSha.map { "\"$it\"" })
variant.buildConfigField("String", "BUILD_TIME", buildTime.map { "\"$it\"" })
if (isUnstableBuild) {
variant.outputs.forEach { output ->
val versionName = output.versionName.get()
output.versionName.set(latestCommitCount.map { "$versionName-$it" })
}
}
}
onVariants { variant ->
val resSource = variant.sources.res ?: return@onVariants
val variantName = variant.name.replaceFirstChar { it.uppercase() }
val replaceShortcutsPlaceholderTask = tasks.register<ReplaceShortcutsPlaceholderTask>(
"replace${variantName}ShortcutPlaceholder",
) {
applicationId.set(variant.applicationId)
shortcutsFile.set(projectDir.resolve("src/main/shortcuts.xml"))
}
resSource.addGeneratedSourceDirectory(replaceShortcutsPlaceholderTask) { it.outputDir }
}
onVariants(selector().withFlavor("default" to "standard")) {
// Only excluding in standard flavor because this breaks
// Layout Inspector's Compose tree
it.packaging.resources.excludes.add("META-INF/*.version")
}
}