From 7071657deb6af6a99458d12744da760624c7177b Mon Sep 17 00:00:00 2001 From: Rojikku Date: Sat, 4 Jul 2026 03:02:49 -0400 Subject: [PATCH 01/45] Merge script --- .kei-sync | 1 + scripts/sync-kei.sh | 83 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 .kei-sync create mode 100755 scripts/sync-kei.sh diff --git a/.kei-sync b/.kei-sync new file mode 100644 index 000000000..8a5153c39 --- /dev/null +++ b/.kei-sync @@ -0,0 +1 @@ +2900ef98c70a08e0206e5ae2540153758cdb77f5 diff --git a/scripts/sync-kei.sh b/scripts/sync-kei.sh new file mode 100755 index 000000000..3cdb63720 --- /dev/null +++ b/scripts/sync-kei.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Pulls shared code/build-system/library changes from upstream keiyoushi +# (remote: kei), excluding extension source we don't want in our history +# (src/, lib-multisrc/). +# +# Does NOT commit anything automatically. It fetches kei, applies the +# relevant diff to the working tree/index, and updates .kei-sync, leaving +# everything staged for manual review/splitting into commits. +# +# The .kei-sync file (tracked in git) is the source of truth for "how far +# we've synced" so it survives clones/fresh machines. The kei-sync/kei-last +# tags are just a local convenience derived from it for `git log`/`git diff`. +# +# Usage: scripts/sync-kei.sh + +REMOTE=kei +BRANCH=main +SYNC_FILE=.kei-sync +EXCLUDE_PATHSPECS=(':!src' ':!lib-multisrc') + +cd "$(git rev-parse --show-toplevel)" + +if [[ -n "$(git status --porcelain)" ]]; then + echo "error: working tree is not clean, commit or stash first." >&2 + exit 1 +fi + +if [[ ! -f "$SYNC_FILE" ]]; then + echo "error: $SYNC_FILE not found. Bootstrap it first:" >&2 + echo " echo > $SYNC_FILE && git add $SYNC_FILE && git commit -m 'chore: bootstrap kei sync point'" >&2 + exit 1 +fi + +LAST_SYNC=$(<"$SYNC_FILE") + +echo "Fetching $REMOTE..." +git fetch "$REMOTE" --quiet + +TARGET=$(git rev-parse "$REMOTE/$BRANCH") + +if [[ "$LAST_SYNC" == "$TARGET" ]]; then + echo "Already up to date with $REMOTE/$BRANCH ($TARGET)." + exit 0 +fi + +# Backup: move kei-last to wherever kei-sync currently points, so a botched +# sync can be undone by resetting to kei-last and re-running. +git tag -f kei-last "$LAST_SYNC" >/dev/null +git tag -f kei-sync "$LAST_SYNC" >/dev/null + +WORKBRANCH="kei-sync-$(date +%Y%m%d)" +git checkout -b "$WORKBRANCH" + +echo +echo "Changes to pull in ($LAST_SYNC..$TARGET, excluding src/ and lib-multisrc/):" +git diff --stat "$LAST_SYNC" "$TARGET" -- . "${EXCLUDE_PATHSPECS[@]}" +echo + +PATCH=$(mktemp) +trap 'rm -f "$PATCH"' EXIT +git diff "$LAST_SYNC" "$TARGET" -- . "${EXCLUDE_PATHSPECS[@]}" > "$PATCH" + +if [[ ! -s "$PATCH" ]]; then + echo "No relevant changes outside excluded paths." + echo "$TARGET" > "$SYNC_FILE" + git add "$SYNC_FILE" + git tag -f kei-sync "$TARGET" >/dev/null + exit 0 +fi + +git apply --index --3way "$PATCH" + +echo "$TARGET" > "$SYNC_FILE" +git add "$SYNC_FILE" +git tag -f kei-sync "$TARGET" >/dev/null + +echo +echo "Applied through $TARGET on branch $WORKBRANCH. Changes are staged, nothing committed." +echo "Review and split into commits as usual, then open a PR." +echo +echo "If this sync needs to be redone: git reset --hard kei-last, delete this branch, and re-run." From 0aa0310a69a46d7bd0bba2f669fc2154d4b8f4c8 Mon Sep 17 00:00:00 2001 From: Rojikku Date: Sat, 4 Jul 2026 03:13:20 -0400 Subject: [PATCH 02/45] Allow partial merges --- scripts/sync-kei.sh | 55 +++++++++++++++++++++++++++++++++------------ 1 file changed, 41 insertions(+), 14 deletions(-) diff --git a/scripts/sync-kei.sh b/scripts/sync-kei.sh index 3cdb63720..7a602d7dc 100755 --- a/scripts/sync-kei.sh +++ b/scripts/sync-kei.sh @@ -5,9 +5,12 @@ set -euo pipefail # (remote: kei), excluding extension source we don't want in our history # (src/, lib-multisrc/). # -# Does NOT commit anything automatically. It fetches kei, applies the -# relevant diff to the working tree/index, and updates .kei-sync, leaving -# everything staged for manual review/splitting into commits. +# Does NOT commit anything automatically. It fetches kei and applies each +# changed file's diff independently, staging whatever applies cleanly (or +# with 3-way conflict markers) and leaving genuinely unresolvable files +# untouched, with their raw diff saved for manual merging. .kei-sync is +# only advanced automatically when every file applied; otherwise you +# advance it yourself once the leftovers are dealt with. # # The .kei-sync file (tracked in git) is the source of truth for "how far # we've synced" so it survives clones/fresh machines. The kei-sync/kei-last @@ -19,6 +22,7 @@ REMOTE=kei BRANCH=main SYNC_FILE=.kei-sync EXCLUDE_PATHSPECS=(':!src' ':!lib-multisrc') +REJECT_DIR="$(git rev-parse --git-dir 2>/dev/null || echo .git)/kei-sync-pending" cd "$(git rev-parse --show-toplevel)" @@ -55,14 +59,12 @@ git checkout -b "$WORKBRANCH" echo echo "Changes to pull in ($LAST_SYNC..$TARGET, excluding src/ and lib-multisrc/):" -git diff --stat "$LAST_SYNC" "$TARGET" -- . "${EXCLUDE_PATHSPECS[@]}" +git diff --stat --no-renames "$LAST_SYNC" "$TARGET" -- . "${EXCLUDE_PATHSPECS[@]}" echo -PATCH=$(mktemp) -trap 'rm -f "$PATCH"' EXIT -git diff "$LAST_SYNC" "$TARGET" -- . "${EXCLUDE_PATHSPECS[@]}" > "$PATCH" +mapfile -d '' -t FILES < <(git diff -z --name-only --no-renames "$LAST_SYNC" "$TARGET" -- . "${EXCLUDE_PATHSPECS[@]}") -if [[ ! -s "$PATCH" ]]; then +if [[ ${#FILES[@]} -eq 0 ]]; then echo "No relevant changes outside excluded paths." echo "$TARGET" > "$SYNC_FILE" git add "$SYNC_FILE" @@ -70,14 +72,39 @@ if [[ ! -s "$PATCH" ]]; then exit 0 fi -git apply --index --3way "$PATCH" +rm -rf "$REJECT_DIR" +APPLIED=() +FAILED=() -echo "$TARGET" > "$SYNC_FILE" -git add "$SYNC_FILE" -git tag -f kei-sync "$TARGET" >/dev/null +for f in "${FILES[@]}"; do + if git diff --no-renames "$LAST_SYNC" "$TARGET" -- "$f" | git apply --index --3way - 2>/dev/null; then + APPLIED+=("$f") + else + FAILED+=("$f") + mkdir -p "$REJECT_DIR/$(dirname "$f")" + git diff --no-renames "$LAST_SYNC" "$TARGET" -- "$f" > "$REJECT_DIR/$f.patch" + fi +done echo -echo "Applied through $TARGET on branch $WORKBRANCH. Changes are staged, nothing committed." -echo "Review and split into commits as usual, then open a PR." +echo "Applied cleanly or with conflict markers: ${#APPLIED[@]} file(s)." + +if [[ ${#FAILED[@]} -eq 0 ]]; then + echo "$TARGET" > "$SYNC_FILE" + git add "$SYNC_FILE" + git tag -f kei-sync "$TARGET" >/dev/null + echo + echo "All files applied. Staged on branch $WORKBRANCH, nothing committed." + echo "Review (check for 3-way conflict markers with: git diff --check), split into commits, then open a PR." +else + echo "Needs manual merge: ${#FAILED[@]} file(s):" + printf ' %s\n' "${FAILED[@]}" + echo + echo "Raw upstream diffs for these are saved under $REJECT_DIR/ for reference." + echo "$SYNC_FILE was NOT advanced (still $LAST_SYNC) since not everything applied." + echo "Once you've manually reconciled the files above, advance it yourself:" + echo " echo $TARGET > $SYNC_FILE && git add $SYNC_FILE && git tag -f kei-sync $TARGET" +fi + echo echo "If this sync needs to be redone: git reset --hard kei-last, delete this branch, and re-run." From 5fb019168ee31af2bfa3429c339170b1b63e22a5 Mon Sep 17 00:00:00 2001 From: Rojikku Date: Sat, 4 Jul 2026 03:16:21 -0400 Subject: [PATCH 03/45] Stop evil pager rampage --- scripts/sync-kei.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sync-kei.sh b/scripts/sync-kei.sh index 7a602d7dc..974194cfc 100755 --- a/scripts/sync-kei.sh +++ b/scripts/sync-kei.sh @@ -59,7 +59,7 @@ git checkout -b "$WORKBRANCH" echo echo "Changes to pull in ($LAST_SYNC..$TARGET, excluding src/ and lib-multisrc/):" -git diff --stat --no-renames "$LAST_SYNC" "$TARGET" -- . "${EXCLUDE_PATHSPECS[@]}" +git --no-pager diff --stat --no-renames "$LAST_SYNC" "$TARGET" -- . "${EXCLUDE_PATHSPECS[@]}" echo mapfile -d '' -t FILES < <(git diff -z --name-only --no-renames "$LAST_SYNC" "$TARGET" -- . "${EXCLUDE_PATHSPECS[@]}") From 1be4cec59d690b99a434b7f724383b9c990e930f Mon Sep 17 00:00:00 2001 From: Rojikku Date: Sat, 4 Jul 2026 03:31:15 -0400 Subject: [PATCH 04/45] Lib, Compiler, and Core --- compiler/build.gradle.kts | 10 + .../keiyoushi/processor/SourceProcessor.kt | 298 ++++++++++++++++ ...ols.ksp.processing.SymbolProcessorProvider | 1 + .../kotlin/keiyoushi/annotation/Source.kt | 5 + .../main/kotlin/keiyoushi/network/OkHttp.kt | 328 ++++++++++++++++++ .../keiyoushi/source/CustomUrlPreferences.kt | 89 +++++ .../keiyoushi/source/MirrorPreferences.kt | 35 ++ .../kotlin/keiyoushi/source/UrlActivity.kt | 29 ++ .../kotlin/keiyoushi/utils/JsonElement.kt | 54 +++ core/translations/strings.json | 32 ++ gradlew | 4 +- gradlew.bat | 4 +- lib/publus/src/keiyoushi/lib/publus/Publus.kt | 241 ++++++------- .../src/keiyoushi/lib/publus/PublusAuth.kt | 138 ++++++++ .../src/keiyoushi/lib/publus/PublusDto.kt | 23 ++ .../keiyoushi/lib/publus/PublusInterceptor.kt | 23 +- .../src/keiyoushi/lib/publus/PublusPages.kt | 237 ++++++------- 17 files changed, 1278 insertions(+), 273 deletions(-) create mode 100644 compiler/build.gradle.kts create mode 100644 compiler/src/main/kotlin/keiyoushi/processor/SourceProcessor.kt create mode 100644 compiler/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider create mode 100644 core/src/main/kotlin/keiyoushi/annotation/Source.kt create mode 100644 core/src/main/kotlin/keiyoushi/network/OkHttp.kt create mode 100644 core/src/main/kotlin/keiyoushi/source/CustomUrlPreferences.kt create mode 100644 core/src/main/kotlin/keiyoushi/source/MirrorPreferences.kt create mode 100644 core/src/main/kotlin/keiyoushi/source/UrlActivity.kt create mode 100644 core/src/main/kotlin/keiyoushi/utils/JsonElement.kt create mode 100644 core/translations/strings.json create mode 100644 lib/publus/src/keiyoushi/lib/publus/PublusAuth.kt diff --git a/compiler/build.gradle.kts b/compiler/build.gradle.kts new file mode 100644 index 000000000..3ed7f0687 --- /dev/null +++ b/compiler/build.gradle.kts @@ -0,0 +1,10 @@ +plugins { + kotlin("jvm") + kotlin("plugin.serialization") +} + +dependencies { + implementation(libs.ksp.api) + implementation(libs.kotlinpoet.ksp) + implementation(libs.kotlin.json) +} diff --git a/compiler/src/main/kotlin/keiyoushi/processor/SourceProcessor.kt b/compiler/src/main/kotlin/keiyoushi/processor/SourceProcessor.kt new file mode 100644 index 000000000..7f70e0d52 --- /dev/null +++ b/compiler/src/main/kotlin/keiyoushi/processor/SourceProcessor.kt @@ -0,0 +1,298 @@ +package keiyoushi.processor + +import com.google.devtools.ksp.getAllSuperTypes +import com.google.devtools.ksp.processing.CodeGenerator +import com.google.devtools.ksp.processing.Dependencies +import com.google.devtools.ksp.processing.KSPLogger +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.processing.SymbolProcessorProvider +import com.google.devtools.ksp.symbol.KSAnnotated +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.CodeBlock +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.MemberName +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import com.squareup.kotlinpoet.PropertySpec +import com.squareup.kotlinpoet.TypeSpec +import com.squareup.kotlinpoet.asClassName +import com.squareup.kotlinpoet.ksp.toClassName +import com.squareup.kotlinpoet.ksp.writeTo +import java.io.File +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +@Serializable +data class PartialLocaleStrings( + val mirrorTitle: String? = null, + val customUrlTitle: String? = null, + val customUrlDialogMessage: String? = null, +) + +data class LocaleStrings( + val mirrorTitle: String, + val customUrlTitle: String, + val customUrlDialogMessage: String, +) + +@Serializable +data class BaseUrlSpecData( + val type: String, + val urls: List, +) { + val defaultUrl: String get() = urls.first() +} + +@Serializable +data class SourceDef( + val name: String, + val lang: String, + val id: Long, + val baseUrl: BaseUrlSpecData, + val skipCodeGen: Boolean = false, +) + +private val configurable = ClassName("eu.kanade.tachiyomi.source", "ConfigurableSource") +private val preferenceScreen = ClassName("androidx.preference", "PreferenceScreen") +private val mirrorPrefsClass = ClassName("keiyoushi.source", "MirrorPreferences") +private val customUrlPrefsClass = ClassName("keiyoushi.source", "CustomUrlPreferences") +private val getPreferencesFn = MemberName("keiyoushi.utils", "getPreferences") + +class SourceProcessor( + private val codeGenerator: CodeGenerator, + private val options: Map, + private val logger: KSPLogger, +) : SymbolProcessor { + + private val translations: Map by lazy { + val path = options["kei_translations"] ?: return@lazy emptyMap() + runCatching { + Json.decodeFromString>(File(path).readText()) + }.getOrElse { + logger.warn("kei_translations: ${it.message}") + emptyMap() + } + } + + private fun stringsForLang(lang: String): LocaleStrings { + val en = translations.getValue("en") + val locale = translations[lang] ?: translations[lang.substringBefore("-")] + return LocaleStrings( + mirrorTitle = locale?.mirrorTitle ?: en.mirrorTitle!!, + customUrlTitle = locale?.customUrlTitle ?: en.customUrlTitle!!, + customUrlDialogMessage = locale?.customUrlDialogMessage ?: en.customUrlDialogMessage!!, + ) + } + + private var invoked = false + + override fun process(resolver: Resolver): List { + if (invoked) return emptyList() + invoked = true + + val annotatedClasses = resolver + .getSymbolsWithAnnotation("keiyoushi.annotation.Source") + .filterIsInstance() + .toList() + + val sourcesJson = options["kei_sources"] + + if (annotatedClasses.isEmpty() && sourcesJson == null) return emptyList() + + if (annotatedClasses.isEmpty()) { + logger.error("source {} blocks present but no @Source class found — annotate your source class with @Source") + return emptyList() + } + + if (annotatedClasses.size > 1) { + val names = annotatedClasses.joinToString { it.qualifiedName?.asString() ?: it.simpleName.asString() } + logger.error("exactly one @Source class allowed per module, found: $names", annotatedClasses.first()) + return emptyList() + } + + val annotated = annotatedClasses.single() + + if (sourcesJson == null) { + logger.error("@Source found but no source {} blocks in build.gradle.kts", annotated) + return emptyList() + } + + val sources = Json.decodeFromString>(sourcesJson) + if (sources.isEmpty()) { + logger.error("@Source found but source list is empty", annotated) + return emptyList() + } + + val pkg = annotated.packageName.asString() + val annotatedClass = annotated.toClassName() + val isConfigurable = annotated.getAllSuperTypes() + .any { it.declaration.qualifiedName?.asString() == "eu.kanade.tachiyomi.source.ConfigurableSource" } + + val generatedClass = when { + sources.size == 1 && sources.single().skipCodeGen -> buildPassthroughClass(annotatedClass) + sources.size == 1 -> buildSingleSourceClass(annotatedClass, sources.single(), isConfigurable) + else -> buildSourceFactoryClass(annotatedClass, sources, isConfigurable) + } + + FileSpec.builder(pkg, "ExtensionGenerated") + .addType(generatedClass) + .build() + .writeTo(codeGenerator, Dependencies(false, annotated.containingFile!!)) + + return emptyList() + } + + private fun buildPassthroughClass(annotatedClass: ClassName): TypeSpec = + TypeSpec.classBuilder("ExtensionGenerated") + .addModifiers(KModifier.INTERNAL) + .superclass(annotatedClass) + .build() + + private fun buildSingleSourceClass( + annotatedClass: ClassName, + source: SourceDef, + isConfigurable: Boolean, + ): TypeSpec = TypeSpec.classBuilder("ExtensionGenerated") + .addModifiers(KModifier.INTERNAL) + .superclass(annotatedClass) + .applySourceMembers(source, isConfigurable) + .build() + + private fun buildSourceFactoryClass( + annotatedClass: ClassName, + sources: List, + isConfigurable: Boolean, + ): TypeSpec { + val sourceFactoryType = ClassName("eu.kanade.tachiyomi.source", "SourceFactory") + val sourceType = ClassName("eu.kanade.tachiyomi.source", "Source") + + val createSourcesCode = CodeBlock.builder() + .add("return listOf(\n") + .indent() + .apply { + for (source in sources) { + add( + "%L,\n", + TypeSpec.anonymousClassBuilder() + .superclass(annotatedClass) + .applySourceMembers(source, isConfigurable) + .build(), + ) + } + } + .unindent() + .add(")") + .build() + + return TypeSpec.classBuilder("ExtensionGenerated") + .addModifiers(KModifier.INTERNAL) + .addSuperinterface(sourceFactoryType) + .addFunction( + FunSpec.builder("createSources") + .addModifiers(KModifier.OVERRIDE) + .returns(List::class.asClassName().parameterizedBy(sourceType)) + .addCode(createSourcesCode) + .build(), + ) + .build() + } + + private fun TypeSpec.Builder.applySourceMembers(source: SourceDef, isConfigurable: Boolean): TypeSpec.Builder = apply { + addProperty( + PropertySpec.builder("name", String::class.asClassName(), KModifier.OVERRIDE) + .getter(FunSpec.getterBuilder().addStatement("return %S", source.name).build()) + .build(), + ) + addProperty( + PropertySpec.builder("lang", String::class.asClassName(), KModifier.OVERRIDE) + .getter(FunSpec.getterBuilder().addStatement("return %S", source.lang).build()) + .build(), + ) + addProperty( + PropertySpec.builder("id", Long::class.asClassName(), KModifier.OVERRIDE) + .getter(FunSpec.getterBuilder().addStatement("return %LL", source.id).build()) + .build(), + ) + + val urlSpec = source.baseUrl + when (urlSpec.type) { + "static" -> { + addProperty( + PropertySpec.builder("baseUrl", String::class.asClassName(), KModifier.OVERRIDE) + .getter(FunSpec.getterBuilder().addStatement("return %S", urlSpec.defaultUrl).build()) + .build(), + ) + } + "mirrors" -> { + val strings = stringsForLang(source.lang) + val mirrorsArg = CodeBlock.builder().apply { + urlSpec.urls.forEachIndexed { i, url -> + if (i > 0) add(", ") + add("%S", url) + } + }.build() + addProperty( + PropertySpec.builder("mirrorPrefs", mirrorPrefsClass) + .addModifiers(KModifier.PRIVATE) + .delegate( + CodeBlock.of( + "lazy { %T(%M(id), arrayOf(%L), title = %S) }", + mirrorPrefsClass, getPreferencesFn, mirrorsArg, strings.mirrorTitle, + ), + ).build(), + ) + addProperty( + PropertySpec.builder("baseUrl", String::class.asClassName(), KModifier.OVERRIDE) + .getter(FunSpec.getterBuilder().addStatement("return mirrorPrefs.baseUrl").build()) + .build(), + ) + addPreferenceScreen(isConfigurable) { addStatement("mirrorPrefs.setupPreferenceScreen(screen)") } + if (!isConfigurable) addSuperinterface(configurable) + } + "custom" -> { + val strings = stringsForLang(source.lang) + addProperty( + PropertySpec.builder("customUrlPrefs", customUrlPrefsClass) + .addModifiers(KModifier.PRIVATE) + .delegate( + CodeBlock.of( + "lazy { %T(%M(id), %S, title = %S, dialogMessage = %S) }", + customUrlPrefsClass, getPreferencesFn, urlSpec.defaultUrl, + strings.customUrlTitle, strings.customUrlDialogMessage, + ), + ).build(), + ) + addProperty( + PropertySpec.builder("baseUrl", String::class.asClassName(), KModifier.OVERRIDE) + .getter(FunSpec.getterBuilder().addStatement("return customUrlPrefs.baseUrl").build()) + .build(), + ) + addPreferenceScreen(isConfigurable) { addStatement("customUrlPrefs.setupPreferenceScreen(screen)") } + if (!isConfigurable) addSuperinterface(configurable) + } + } + } + + private fun TypeSpec.Builder.addPreferenceScreen( + callSuper: Boolean, + addPrefs: FunSpec.Builder.() -> Unit, + ) { + addFunction( + FunSpec.builder("setupPreferenceScreen") + .addModifiers(KModifier.OVERRIDE) + .addParameter("screen", preferenceScreen) + .apply(addPrefs) + .apply { if (callSuper) addStatement("super.setupPreferenceScreen(screen)") } + .build(), + ) + } + + class Provider : SymbolProcessorProvider { + override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor = + SourceProcessor(environment.codeGenerator, environment.options, environment.logger) + } +} diff --git a/compiler/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider b/compiler/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider new file mode 100644 index 000000000..618574dee --- /dev/null +++ b/compiler/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider @@ -0,0 +1 @@ +keiyoushi.processor.SourceProcessor$Provider diff --git a/core/src/main/kotlin/keiyoushi/annotation/Source.kt b/core/src/main/kotlin/keiyoushi/annotation/Source.kt new file mode 100644 index 000000000..ceca76f8b --- /dev/null +++ b/core/src/main/kotlin/keiyoushi/annotation/Source.kt @@ -0,0 +1,5 @@ +package keiyoushi.annotation + +@Retention(AnnotationRetention.SOURCE) +@Target(AnnotationTarget.CLASS) +annotation class Source diff --git a/core/src/main/kotlin/keiyoushi/network/OkHttp.kt b/core/src/main/kotlin/keiyoushi/network/OkHttp.kt new file mode 100644 index 000000000..258ba818e --- /dev/null +++ b/core/src/main/kotlin/keiyoushi/network/OkHttp.kt @@ -0,0 +1,328 @@ +package keiyoushi.network + +import eu.kanade.tachiyomi.network.await +import eu.kanade.tachiyomi.network.awaitSuccess +import eu.kanade.tachiyomi.source.online.HttpSource +import okhttp3.CacheControl +import okhttp3.Headers +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.Response +import kotlin.time.Duration.Companion.minutes + +/** + * Default cache control. + */ +private val DEFAULT_CACHE_CONTROL = CacheControl.Builder().maxAge(10.minutes).build() + +/** + * Executes a GET request asynchronously and returns the response. + * + * @param url The [HttpUrl] to request. + * @param headers The headers to include in the request. + * @param cacheControl The cache control settings for the request. + * @param ensureSuccess If true, throws an exception if the response code is not 2xx. + * @return The HTTP [Response]. + */ +suspend fun OkHttpClient.get( + url: HttpUrl, + headers: Headers, + cacheControl: CacheControl = DEFAULT_CACHE_CONTROL, + ensureSuccess: Boolean = true, +): Response { + val request = Request.Builder() + .url(url) + .headers(headers) + .cacheControl(cacheControl) + .build() + val call = newCall(request) + + return if (ensureSuccess) { + call.awaitSuccess() + } else { + call.await() + } +} + +/** + * Executes a GET request asynchronously using a URL string and returns the response. + * + * @param url The URL string to request. + * @param headers The headers to include in the request. + * @param cacheControl The cache control settings for the request. + * @param ensureSuccess If true, throws an exception if the response code is not 2xx. + * @return The HTTP [Response]. + */ +suspend fun OkHttpClient.get( + url: String, + headers: Headers, + cacheControl: CacheControl = DEFAULT_CACHE_CONTROL, + ensureSuccess: Boolean = true, +): Response = get(url.toHttpUrl(), headers, cacheControl, ensureSuccess) + +/** + * Executes a GET request asynchronously, automatically retrieving the headers from the + * current [HttpSource] context receiver. + * + * @param url The [HttpUrl] to request. + * @param cacheControl The cache control settings for the request. + * @param ensureSuccess If true, throws an exception if the response code is not 2xx. + * @return The HTTP [Response]. + */ +context(source: HttpSource) +suspend fun OkHttpClient.get( + url: HttpUrl, + cacheControl: CacheControl = DEFAULT_CACHE_CONTROL, + ensureSuccess: Boolean = true, +): Response = get(url, source.headers, cacheControl, ensureSuccess) + +/** + * Executes a GET request asynchronously using a URL string, automatically retrieving the + * headers from the current [HttpSource] context receiver. + * + * @param url The URL string to request. + * @param cacheControl The cache control settings for the request. + * @param ensureSuccess If true, throws an exception if the response code is not 2xx. + * @return The HTTP [Response]. + */ +context(source: HttpSource) +suspend fun OkHttpClient.get( + url: String, + cacheControl: CacheControl = DEFAULT_CACHE_CONTROL, + ensureSuccess: Boolean = true, +): Response = get(url, source.headers, cacheControl, ensureSuccess) + +/** + * Executes a POST request asynchronously and returns the response. + * + * @param url The [HttpUrl] to request. + * @param headers The headers to include in the request. + * @param body The request body payload. + * @param ensureSuccess If true, throws an exception if the response code is not 2xx. + * @return The HTTP [Response]. + */ +suspend fun OkHttpClient.post( + url: HttpUrl, + headers: Headers, + body: RequestBody, + ensureSuccess: Boolean = true, +): Response { + val request = Request.Builder() + .url(url) + .headers(headers) + .post(body) + .build() + val call = newCall(request) + + return if (ensureSuccess) { + call.awaitSuccess() + } else { + call.await() + } +} + +/** + * Executes a POST request asynchronously using a URL string and returns the response. + * + * @param url The URL string to request. + * @param headers The headers to include in the request. + * @param body The request body payload. + * @param ensureSuccess If true, throws an exception if the response code is not 2xx. + * @return The HTTP [Response]. + */ +suspend fun OkHttpClient.post( + url: String, + headers: Headers, + body: RequestBody, + ensureSuccess: Boolean = true, +): Response = post(url.toHttpUrl(), headers, body, ensureSuccess) + +/** + * Executes a POST request asynchronously, automatically retrieving the + * headers from the current [HttpSource] context receiver. + * + * @param url The [HttpUrl] to request. + * @param body The request body payload. + * @param ensureSuccess If true, throws an exception if the response code is not 2xx. + * @return The HTTP [Response]. + */ +context(source: HttpSource) +suspend fun OkHttpClient.post( + url: HttpUrl, + body: RequestBody, + ensureSuccess: Boolean = true, +): Response = post(url, source.headers, body, ensureSuccess) + +/** + * Executes a POST request asynchronously using a URL string, automatically retrieving the + * headers from the current [HttpSource] context receiver. + * + * @param url The URL string to request. + * @param body The request body payload. + * @param ensureSuccess If true, throws an exception if the response code is not 2xx. + * @return The HTTP [Response]. + */ +context(source: HttpSource) +suspend fun OkHttpClient.post( + url: String, + body: RequestBody, + ensureSuccess: Boolean = true, +): Response = post(url, source.headers, body, ensureSuccess) + +/** + * Executes a PUT request asynchronously and returns the response. + * + * @param url The [HttpUrl] to request. + * @param headers The headers to include in the request. + * @param body The request body payload. + * @param ensureSuccess If true, throws an exception if the response code is not 2xx. + * @return The HTTP [Response]. + */ +suspend fun OkHttpClient.put( + url: HttpUrl, + headers: Headers, + body: RequestBody, + ensureSuccess: Boolean = true, +): Response { + val request = Request.Builder() + .url(url) + .headers(headers) + .put(body) + .build() + val call = newCall(request) + + return if (ensureSuccess) { + call.awaitSuccess() + } else { + call.await() + } +} + +/** + * Executes a PUT request asynchronously using a URL string and returns the response. + * + * @param url The URL string to request. + * @param headers The headers to include in the request. + * @param body The request body payload. + * @param ensureSuccess If true, throws an exception if the response code is not 2xx. + * @return The HTTP [Response]. + */ +suspend fun OkHttpClient.put( + url: String, + headers: Headers, + body: RequestBody, + ensureSuccess: Boolean = true, +): Response = put(url.toHttpUrl(), headers, body, ensureSuccess) + +/** + * Executes a PUT request asynchronously using a URL string, automatically retrieving the + * headers from the current [HttpSource] context receiver. + * + * @param url The URL string to request. + * @param body The request body payload. + * @param ensureSuccess If true, throws an exception if the response code is not 2xx. + * @return The HTTP [Response]. + */ +context(source: HttpSource) +suspend fun OkHttpClient.put( + url: String, + body: RequestBody, + ensureSuccess: Boolean = true, +): Response = put(url, source.headers, body, ensureSuccess) + +/** + * Executes a PUT request asynchronously, automatically retrieving the + * headers from the current [HttpSource] context receiver. + * + * @param url The [HttpUrl] to request. + * @param body The request body payload. + * @param ensureSuccess If true, throws an exception if the response code is not 2xx. + * @return The HTTP [Response]. + */ +context(source: HttpSource) +suspend fun OkHttpClient.put( + url: HttpUrl, + body: RequestBody, + ensureSuccess: Boolean = true, +): Response = put(url, source.headers, body, ensureSuccess) + +/** + * Executes a HEAD request asynchronously and returns the response. + * + * @param url The [HttpUrl] to request. + * @param headers The headers to include in the request. + * @param cacheControl The cache control settings for the request. + * @param ensureSuccess If true, throws an exception if the response code is not 2xx. + * @return The HTTP [Response]. + */ +suspend fun OkHttpClient.head( + url: HttpUrl, + headers: Headers, + cacheControl: CacheControl = DEFAULT_CACHE_CONTROL, + ensureSuccess: Boolean = true, +): Response { + val request = Request.Builder() + .url(url) + .headers(headers) + .cacheControl(cacheControl) + .head() + .build() + val call = newCall(request) + + return if (ensureSuccess) { + call.awaitSuccess() + } else { + call.await() + } +} + +/** + * Executes a HEAD request asynchronously using a URL string and returns the response. + * + * @param url The URL string to request. + * @param headers The headers to include in the request. + * @param cacheControl The cache control settings for the request. + * @param ensureSuccess If true, throws an exception if the response code is not 2xx. + * @return The HTTP [Response]. + */ +suspend fun OkHttpClient.head( + url: String, + headers: Headers, + cacheControl: CacheControl = DEFAULT_CACHE_CONTROL, + ensureSuccess: Boolean = true, +): Response = head(url.toHttpUrl(), headers, cacheControl, ensureSuccess) + +/** + * Executes a HEAD request asynchronously using a URL string, automatically retrieving the + * headers from the current [HttpSource] context receiver. + * + * @param url The URL string to request. + * @param cacheControl The cache control settings for the request. + * @param ensureSuccess If true, throws an exception if the response code is not 2xx. + * @return The HTTP [Response]. + */ +context(source: HttpSource) +suspend fun OkHttpClient.head( + url: String, + cacheControl: CacheControl = DEFAULT_CACHE_CONTROL, + ensureSuccess: Boolean = true, +): Response = head(url, source.headers, cacheControl, ensureSuccess) + +/** + * Executes a HEAD request asynchronously, automatically retrieving the + * headers from the current [HttpSource] context receiver. + * + * @param url The [HttpUrl] to request. + * @param cacheControl The cache control settings for the request. + * @param ensureSuccess If true, throws an exception if the response code is not 2xx. + * @return The HTTP [Response]. + */ +context(source: HttpSource) +suspend fun OkHttpClient.head( + url: HttpUrl, + cacheControl: CacheControl = DEFAULT_CACHE_CONTROL, + ensureSuccess: Boolean = true, +): Response = head(url, source.headers, cacheControl, ensureSuccess) diff --git a/core/src/main/kotlin/keiyoushi/source/CustomUrlPreferences.kt b/core/src/main/kotlin/keiyoushi/source/CustomUrlPreferences.kt new file mode 100644 index 000000000..46e5ae9b5 --- /dev/null +++ b/core/src/main/kotlin/keiyoushi/source/CustomUrlPreferences.kt @@ -0,0 +1,89 @@ +package keiyoushi.source + +import android.content.SharedPreferences +import android.text.Editable +import android.text.TextWatcher +import android.widget.Button +import androidx.preference.EditTextPreference +import androidx.preference.PreferenceScreen +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull + +class CustomUrlPreferences( + private val preferences: SharedPreferences, + private val defaultUrl: String, + private val title: String, + private val dialogMessage: String, +) { + private val prefBaseKey = "overrideBaseUrl" + private val prefDefaultKey = "defaultBaseUrl" + + init { + val storedDefault = preferences.getString(prefDefaultKey, null) + if (storedDefault != defaultUrl) { + preferences.edit() + .putString(prefBaseKey, defaultUrl) + .putString(prefDefaultKey, defaultUrl) + .apply() + } + } + + val baseUrl: String + get() = preferences.getString(prefBaseKey, defaultUrl) ?: defaultUrl + + fun setupPreferenceScreen(screen: PreferenceScreen) { + EditTextPreference(screen.context).apply { + key = prefBaseKey + title = this@CustomUrlPreferences.title + dialogTitle = this@CustomUrlPreferences.title + dialogMessage = this@CustomUrlPreferences.dialogMessage + + val currentValue = preferences.getString(prefBaseKey, null) + summary = currentValue?.takeIf { it.isNotBlank() } ?: defaultUrl + setDefaultValue(defaultUrl) + + setOnBindEditTextListener { editText -> + editText.hint = defaultUrl + editText.setHorizontallyScrolling(true) + editText.post { editText.selectAll() } + editText.addTextChangedListener( + object : TextWatcher { + override fun afterTextChanged(editable: Editable?) { + val text = editable?.toString() ?: "" + val isValid = text.isBlank() || text.toHttpUrlOrNull() != null + editText.rootView.findViewById