Compare commits

..

No commits in common. "main" and "v1.13.5-runner-test-20260426165311" have entirely different histories.

289 changed files with 3440 additions and 7966 deletions

View file

@ -1,16 +1,9 @@
name: Forgejo Release Builder
# NOTE: keep Forgejo action refs node20-compatible for runner v3.5.1.
on:
push:
tags:
- v*
workflow_dispatch:
inputs:
tag:
description: 'Release tag to build and publish (e.g. v1.13.6). Must already exist on the remote.'
required: true
type: string
permissions:
contents: write
@ -21,75 +14,32 @@ concurrency:
jobs:
release:
runs-on: desktop-release
runs-on: forgejo-release
steps:
- name: Clone repository
shell: bash
env:
FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DISPATCH_TAG: ${{ github.event.inputs.tag }}
run: |
set -euo pipefail
rm -rf .git
git init .
origin_url="${GITHUB_SERVER_URL#https://}"
origin_url="${origin_url#http://}"
git remote add origin "https://copilot:${FORGEJO_TOKEN}@${origin_url}/${GITHUB_REPOSITORY}.git"
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
git -c http.sslVerify=false fetch --tags --prune origin "refs/tags/${DISPATCH_TAG}"
else
git -c http.sslVerify=false fetch --tags --prune origin "${GITHUB_SHA}"
fi
git checkout --force FETCH_HEAD
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Set up JDK
shell: bash
run: |
set -euo pipefail
pkg_install() {
if [ "$(id -u)" -eq 0 ]; then
"$@"
elif command -v sudo >/dev/null 2>&1; then
sudo "$@"
else
echo "Need root or sudo to install packages."
exit 1
fi
}
if command -v java >/dev/null 2>&1; then
java -version
else
echo "PATH=$PATH"
echo "pkg managers: dnf=$(command -v dnf || true) apt=$(command -v apt-get || true) apk=$(command -v apk || true) pacman=$(command -v pacman || true)"
if [ -x /usr/bin/dnf ] || command -v dnf >/dev/null 2>&1; then
pkg_install dnf install -y java-17-openjdk-devel
elif [ -x /usr/bin/apt-get ] || command -v apt-get >/dev/null 2>&1; then
pkg_install apt-get update
pkg_install apt-get install -y openjdk-17-jdk
elif [ -x /sbin/apk ] || [ -x /usr/sbin/apk ] || command -v apk >/dev/null 2>&1; then
pkg_install apk add --no-cache openjdk17
elif [ -x /usr/bin/pacman ] || command -v pacman >/dev/null 2>&1; then
pkg_install pacman -Sy --noconfirm jdk17-openjdk
else
echo "No supported package manager found for JDK installation."
exit 1
fi
java -version
fi
if command -v javac >/dev/null 2>&1; then
javac -version
java_home="$(dirname "$(dirname "$(readlink -f "$(command -v javac)")")")"
echo "JAVA_HOME=$java_home" >> "$GITHUB_ENV"
fi
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: 17
distribution: temurin
- name: Configure persistent Gradle cache
shell: bash
run: |
set -euo pipefail
gradle_home="${KOMIKKU_GRADLE_CACHE:-${HOME}/.cache/komikku-ci-gradle}"
mkdir -p "${gradle_home}"
echo "GRADLE_USER_HOME=${gradle_home}" >> "${GITHUB_ENV}"
echo "Gradle dependencies/cache: ${gradle_home}"
- name: Write google-services.json
uses: DamianReeves/write-file-action@6929a9a6d1807689191dcc8bbe62b54d70a32b42 # v1.3
with:
path: app/google-services.json
contents: ${{ secrets.GOOGLE_SERVICES_JSON }}
write-mode: overwrite
- name: Write client_secrets.json
uses: DamianReeves/write-file-action@6929a9a6d1807689191dcc8bbe62b54d70a32b42 # v1.3
with:
path: app/src/main/assets/client_secrets.json
contents: ${{ secrets.GOOGLE_CLIENT_SECRETS_JSON }}
write-mode: overwrite
- name: Prepare release metadata
id: prepare
@ -97,233 +47,46 @@ jobs:
run: |
set -euo pipefail
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
version_tag="${{ github.event.inputs.tag }}"
else
version_tag="${GITHUB_REF_NAME:-${GITHUB_REF#refs/tags/}}"
fi
version_display="$(printf '%s' "$version_tag" | sed -E 's/^v//' )"
version_tag="${GITHUB_REF_NAME:-${GITHUB_REF#refs/tags/}}"
previous_tag="$(git tag --list 'v*' --sort=-version:refname | grep -Fxv "$version_tag" | head -n 1 || true)"
upstream_repo="komikku-app/komikku"
upstream_ref=""
upstream_base=""
for ref in master main; do
candidate="$(git ls-remote "https://github.com/${upstream_repo}.git" "refs/heads/${ref}" | awk '{print $1}' || true)"
if [ -n "$candidate" ]; then
upstream_ref="$ref"
upstream_base="$candidate"
break
fi
done
echo "VERSION_TAG=$version_tag" >> "$GITHUB_OUTPUT"
echo "VERSION_DISPLAY=$version_display" >> "$GITHUB_OUTPUT"
echo "PREV_TAG_NAME=$previous_tag" >> "$GITHUB_OUTPUT"
echo "UPSTREAM_REPO=$upstream_repo" >> "$GITHUB_OUTPUT"
echo "UPSTREAM_REF=$upstream_ref" >> "$GITHUB_OUTPUT"
echo "UPSTREAM_BASE=$upstream_base" >> "$GITHUB_OUTPUT"
if [ -n "$upstream_base" ]; then
git fetch --no-tags --depth=1 "https://github.com/${upstream_repo}.git" "$upstream_base" || true
fi
# Release notes: only commits since the *previous release tag* (short, correct scope).
# Do not use upstream_base..HEAD here — that lists the whole fork delta and is huge.
changelog_commit_limit=50
log_range=""
if [ -n "$previous_tag" ]; then
log_range="${previous_tag}..HEAD"
fi
commit_log_file="$(mktemp /tmp/komikku-commit-logs.XXXXXX)"
declare -a new_lines fix_lines improve_lines other_lines
bucket_for_subject() {
local s="$1"
local b=other
shopt -s nocasematch
local r_feat r_feature r_add r_fix r_perf r_refactor r_chore r_docs r_style r_test r_ci r_build r_improve
r_feat='^feat\([^)]*\):'
r_feature='^feature\([^)]*\):'
r_add='^add\([^)]*\):'
r_fix='^fix\([^)]*\):'
r_perf='^perf\([^)]*\):'
r_refactor='^refactor\([^)]*\):'
r_chore='^chore\([^)]*\):'
r_docs='^docs\([^)]*\):'
r_style='^style\([^)]*\):'
r_test='^test\([^)]*\):'
r_ci='^ci\([^)]*\):'
r_build='^build\([^)]*\):'
r_improve='^improve\([^)]*\):'
if [[ "$s" =~ ^feat: ]] || [[ "$s" =~ $r_feat ]]; then b=new
elif [[ "$s" =~ ^feature: ]] || [[ "$s" =~ $r_feature ]]; then b=new
elif [[ "$s" =~ ^add: ]] || [[ "$s" =~ $r_add ]]; then b=new
elif [[ "$s" =~ ^fix: ]] || [[ "$s" =~ $r_fix ]]; then b=fix
elif [[ "$s" =~ ^perf: ]] || [[ "$s" =~ $r_perf ]]; then b=improve
elif [[ "$s" =~ ^refactor: ]] || [[ "$s" =~ $r_refactor ]]; then b=improve
elif [[ "$s" =~ ^chore: ]] || [[ "$s" =~ $r_chore ]]; then b=improve
elif [[ "$s" =~ ^docs: ]] || [[ "$s" =~ $r_docs ]]; then b=improve
elif [[ "$s" =~ ^style: ]] || [[ "$s" =~ $r_style ]]; then b=improve
elif [[ "$s" =~ ^test: ]] || [[ "$s" =~ $r_test ]]; then b=improve
elif [[ "$s" =~ ^ci: ]] || [[ "$s" =~ $r_ci ]]; then b=improve
elif [[ "$s" =~ ^build: ]] || [[ "$s" =~ $r_build ]]; then b=improve
elif [[ "$s" =~ ^improve: ]] || [[ "$s" =~ $r_improve ]]; then b=improve
fi
shopt -u nocasematch
printf '%s' "$b"
}
clear_buckets() {
new_lines=() fix_lines=() improve_lines=() other_lines=()
}
fill_buckets_from_git() {
local rev_range="$1"
shift
while IFS= read -r line || [ -n "${line:-}" ]; do
[ -z "${line:-}" ] && continue
subject="${line%%$'\x1f'*}"
author="${line#*$'\x1f'}"
entry="- ${subject} (@${author})"
case "$(bucket_for_subject "$subject")" in
new) new_lines+=("$entry") ;;
fix) fix_lines+=("$entry") ;;
improve) improve_lines+=("$entry") ;;
*) other_lines+=("$entry") ;;
esac
done < <(git log "$@" --pretty=tformat:'%s%x1f%an' "$rev_range")
}
emit_buckets_from_arrays() {
local any=0
if [ ${#new_lines[@]} -gt 0 ]; then
printf '%s\n' "##### New" ""
printf '%s\n' "${new_lines[@]}"
printf '\n'
any=1
fi
if [ ${#fix_lines[@]} -gt 0 ]; then
printf '%s\n' "##### Fix" ""
printf '%s\n' "${fix_lines[@]}"
printf '\n'
any=1
fi
if [ ${#improve_lines[@]} -gt 0 ]; then
printf '%s\n' "##### Improve" ""
printf '%s\n' "${improve_lines[@]}"
printf '\n'
any=1
fi
if [ ${#other_lines[@]} -gt 0 ]; then
printf '%s\n' "##### Other" ""
printf '%s\n' "${other_lines[@]}"
printf '\n'
any=1
fi
[ "$any" -eq 1 ]
}
if [ -n "$log_range" ]; then
total_in_range="$(git rev-list --count "${log_range}" 2>/dev/null || printf '0')"
clear_buckets
fill_buckets_from_git "$log_range" "-n${changelog_commit_limit}"
{
if emit_buckets_from_arrays; then
if [ "${total_in_range}" -gt "${changelog_commit_limit}" ] 2>/dev/null; then
printf '%s\n' "" "_Showing the ${changelog_commit_limit} most recent commits since ${previous_tag}._" \
"_Older commits in this release: use **Full Changelog** below._" ""
fi
else
printf '%s\n' "##### Other" "" "- No commit messages in this tag range." ""
fi
} > "${commit_log_file}"
commit_logs="$(git log --pretty=format:'- %s (@%an)' "${previous_tag}..HEAD")"
else
printf '%s\n' "##### Other" "" "- First release tag, or no prior \`v*\` tag found — see repository history." "" > "${commit_log_file}"
commit_logs='- Initial release'
fi
# Do not pass multiline commit text via step outputs / env (breaks some Forgejo/act_runner
# runners before the shell starts — empty logs). Only pass a temp file path.
echo "COMMIT_LOG_FILE=${commit_log_file}" >> "${GITHUB_ENV}"
{
echo 'COMMIT_LOGS<<EOF'
printf '%s\n' "$commit_logs"
echo 'EOF'
} >> "$GITHUB_OUTPUT"
- name: Set up Gradle
shell: bash
run: |
set -euo pipefail
sdk_candidates=(
"${ANDROID_HOME:-}"
"${ANDROID_SDK_ROOT:-}"
"/opt/android-sdk-linux"
"/home/yuna/Android/Sdk"
"/home/runner/Android/Sdk"
"/root/Android/Sdk"
)
sdk_dir=""
for candidate in "${sdk_candidates[@]}"; do
if [ -n "$candidate" ] && [ -d "$candidate/platforms" ] && [ -d "$candidate/build-tools" ]; then
sdk_dir="$candidate"
break
fi
done
if [ -z "$sdk_dir" ]; then
echo "Android SDK not found. Checked:"
for candidate in "${sdk_candidates[@]}"; do
[ -n "$candidate" ] && echo " - $candidate"
done
exit 1
fi
export ANDROID_HOME="$sdk_dir"
export ANDROID_SDK_ROOT="$sdk_dir"
echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
echo "ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT" >> "$GITHUB_ENV"
sdkmanager_bin="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager"
if [ -x "$sdkmanager_bin" ]; then
if [ ! -x "$ANDROID_HOME/build-tools/35.0.0/aidl" ]; then
echo "Installing Android SDK components via sdkmanager..."
set +o pipefail
yes | "$sdkmanager_bin" --licenses >/dev/null || true
yes | "$sdkmanager_bin" "platform-tools" "build-tools;35.0.0"
set -o pipefail
else
echo "Android build-tools 35.0.0 present; skipping sdkmanager (saves time)."
fi
else
echo "sdkmanager not found at $sdkmanager_bin"
fi
if [ ! -x "$ANDROID_HOME/build-tools/35.0.0/aidl" ]; then
echo "Expected executable missing: $ANDROID_HOME/build-tools/35.0.0/aidl"
ls -la "$ANDROID_HOME/build-tools" || true
exit 1
fi
printf 'sdk.dir=%s\n' "$ANDROID_HOME" > local.properties
chmod +x ./gradlew
./gradlew --no-daemon --max-workers=1 \
"-Dorg.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m -XX:ReservedCodeCacheSize=256m -Dfile.encoding=UTF-8" \
-Dorg.gradle.parallel=false \
-Dorg.gradle.configureondemand=false \
-Dorg.gradle.vfs.watch=false \
-Dkotlin.daemon.enabled=false \
"-Dkotlin.daemon.jvmargs=-Xmx2048m,-XX:MaxMetaspaceSize=512m,-XX:ReservedCodeCacheSize=256m" \
-Dkotlin.compiler.execution.strategy=in-process \
-Pkotlin.incremental=true \
--version
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5
- name: Check code format
run: ./gradlew spotlessCheck
- name: Build app
run: |
if [ -n "${ANDROID_HOME:-}" ]; then
printf 'sdk.dir=%s\n' "$ANDROID_HOME" > local.properties
fi
./gradlew --no-daemon --max-workers=1 \
"-Dorg.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m -XX:ReservedCodeCacheSize=256m -Dfile.encoding=UTF-8" \
-Dorg.gradle.parallel=false \
-Dorg.gradle.configureondemand=false \
-Dorg.gradle.vfs.watch=false \
-Dkotlin.daemon.enabled=false \
"-Dkotlin.daemon.jvmargs=-Xmx2048m,-XX:MaxMetaspaceSize=512m,-XX:ReservedCodeCacheSize=256m" \
-Dkotlin.compiler.execution.strategy=in-process \
-Pkotlin.incremental=true \
--stacktrace \
assembleRelease -Penable-updater -Pdisable-code-shrink
run: ./gradlew assembleRelease -Pinclude-telemetry -Penable-updater
- name: Run unit tests
run: ./gradlew testReleaseUnitTest
- name: Sign APK
uses: r0adkll/sign-android-release@f30bdd30588842ac76044ecdbd4b6d0e3e813478
with:
releaseDirectory: app/build/outputs/apk/release
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
alias: ${{ secrets.ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
BUILD_TOOLS_VERSION: '35.0.1'
- name: Rename release artifacts
shell: bash
@ -332,113 +95,13 @@ jobs:
version_tag="${{ steps.prepare.outputs.VERSION_TAG }}"
mv app/build/outputs/apk/release/app-universal-release-unsigned.apk "Komikku-${version_tag}-unsigned.apk"
mv app/build/outputs/apk/release/app-arm64-v8a-release-unsigned.apk "Komikku-arm64-v8a-${version_tag}-unsigned.apk"
mv app/build/outputs/apk/release/app-armeabi-v7a-release-unsigned.apk "Komikku-armeabi-v7a-${version_tag}-unsigned.apk"
mv app/build/outputs/apk/release/app-x86-release-unsigned.apk "Komikku-x86-${version_tag}-unsigned.apk"
mv app/build/outputs/apk/release/app-x86_64-release-unsigned.apk "Komikku-x86_64-${version_tag}-unsigned.apk"
mv app/build/outputs/apk/release/app-universal-release-unsigned-signed.apk "Komikku-${version_tag}.apk"
mv app/build/outputs/apk/release/app-arm64-v8a-release-unsigned-signed.apk "Komikku-arm64-v8a-${version_tag}.apk"
mv app/build/outputs/apk/release/app-armeabi-v7a-release-unsigned-signed.apk "Komikku-armeabi-v7a-${version_tag}.apk"
mv app/build/outputs/apk/release/app-x86-release-unsigned-signed.apk "Komikku-x86-${version_tag}.apk"
mv app/build/outputs/apk/release/app-x86_64-release-unsigned-signed.apk "Komikku-x86_64-${version_tag}.apk"
if [ -d app/build/outputs/mapping/release ]; then
zip -qr "Komikku-mapping-${version_tag}.zip" app/build/outputs/mapping/release
fi
- name: Sign APKs
shell: bash
env:
SIGNING_KEYSTORE_B64: ${{ secrets.SIGNING_KEYSTORE_B64 }}
SIGNING_KEYSTORE_PASSWORD: ${{ secrets.SIGNING_KEYSTORE_PASSWORD }}
SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS }}
SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }}
run: |
set -euo pipefail
if [ -z "${SIGNING_KEYSTORE_B64}" ] || [ -z "${SIGNING_KEYSTORE_PASSWORD}" ] || [ -z "${SIGNING_KEY_ALIAS}" ] || [ -z "${SIGNING_KEY_PASSWORD}" ]; then
echo "Signing secrets are required for installable release APKs."
echo "Set SIGNING_KEYSTORE_B64, SIGNING_KEYSTORE_PASSWORD, SIGNING_KEY_ALIAS, SIGNING_KEY_PASSWORD."
exit 1
fi
keystore_file="$(mktemp)"
printf '%s' "${SIGNING_KEYSTORE_B64}" | base64 -d > "${keystore_file}"
apksigner_bin=""
if command -v apksigner >/dev/null 2>&1; then
apksigner_bin="$(command -v apksigner)"
elif [ -n "${ANDROID_HOME:-}" ] && [ -d "${ANDROID_HOME}/build-tools" ]; then
apksigner_bin="$(ls -1 "${ANDROID_HOME}"/build-tools/*/apksigner 2>/dev/null | sort -V | tail -n 1 || true)"
fi
if [ -z "${apksigner_bin}" ]; then
echo "apksigner not found in PATH or ANDROID_HOME/build-tools."
exit 1
fi
for unsigned_apk in Komikku-*-unsigned.apk; do
[ -e "${unsigned_apk}" ] || continue
signed_apk="${unsigned_apk/-unsigned.apk/.apk}"
"${apksigner_bin}" sign \
--ks "${keystore_file}" \
--ks-key-alias "${SIGNING_KEY_ALIAS}" \
--ks-pass "pass:${SIGNING_KEYSTORE_PASSWORD}" \
--key-pass "pass:${SIGNING_KEY_PASSWORD}" \
--out "${signed_apk}" \
"${unsigned_apk}"
"${apksigner_bin}" verify "${signed_apk}"
done
# Catch corrupt / non-APK artifacts before upload (e.g. truncated build output). Same
# manifest parse path the installer uses for "Paket konnte nicht geparst werden".
aapt_bin=""
if [ -n "${ANDROID_HOME:-}" ] && [ -d "${ANDROID_HOME}/build-tools" ]; then
aapt_bin="$(ls -1 "${ANDROID_HOME}"/build-tools/*/aapt 2>/dev/null | sort -V | tail -n 1 || true)"
fi
if [ -z "${aapt_bin}" ] || [ ! -x "${aapt_bin}" ]; then
echo "aapt not found under ANDROID_HOME; cannot verify APK manifests."
exit 1
fi
for signed_apk in Komikku-*.apk; do
case "${signed_apk}" in *-unsigned.apk) continue ;; esac
echo "Verifying installable APK: ${signed_apk}"
if ! unzip -tqq "${signed_apk}" >/dev/null 2>&1; then
echo "ERROR: ${signed_apk} is not a valid zip (truncated or corrupt build output?)."
exit 1
fi
first_line="$("${aapt_bin}" dump badging "${signed_apk}" 2>/dev/null | head -n 1 || true)"
if ! printf '%s\n' "${first_line}" | grep -q '^package:'; then
echo "ERROR: ${signed_apk} failed manifest parse (first line: ${first_line:-<empty>})."
echo "First 64 bytes (expect PK zip header 504b0304):"
head -c 64 "${signed_apk}" | hexdump -C || true
exit 1
fi
sha256sum "${signed_apk}"
done
- name: Create Obtainium-friendly aliases
shell: bash
run: |
set -euo pipefail
alias_asset() {
src="$1"
dst="$2"
if [ -e "$src" ]; then
cp -f "$src" "$dst"
fi
}
# Only publish signed APK aliases.
alias_asset "$(ls -1t Komikku-arm64-v8a-*.apk 2>/dev/null | head -n1)" "Komikku-arm64-v8a.apk"
for candidate in Komikku-*.apk; do
case "$candidate" in
*arm64-v8a*|*armeabi-v7a*|*x86_64*|*x86*|*-unsigned.apk)
;;
*)
alias_asset "$candidate" "Komikku-universal.apk"
break
;;
esac
done
zip -qr "Komikku-mapping-${version_tag}.zip" app/build/outputs/mapping/release
- name: Create or update release
id: release
@ -446,330 +109,142 @@ jobs:
env:
FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION_TAG: ${{ steps.prepare.outputs.VERSION_TAG }}
VERSION_DISPLAY: ${{ steps.prepare.outputs.VERSION_DISPLAY }}
PREV_TAG_NAME: ${{ steps.prepare.outputs.PREV_TAG_NAME }}
COMMIT_LOGS: ${{ steps.prepare.outputs.COMMIT_LOGS }}
REPO_URL: ${{ github.server_url }}/${{ github.repository }}
REPOSITORY_NAME: ${{ github.repository }}
FORGEJO_CURL_INSECURE: "true"
run: |
set -euo pipefail
echo "Create or update release: shell running; COMMIT_LOG_FILE=${COMMIT_LOG_FILE:-<unset>}"
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PATH:-}"
discover_jq() {
if command -v jq >/dev/null 2>&1; then
JQ_BIN="$(command -v jq)"
export JQ_BIN
return 0
fi
for cand in /usr/bin/jq /usr/local/bin/jq /bin/jq; do
if [ -x "$cand" ]; then
JQ_BIN="$cand"
export JQ_BIN
return 0
fi
done
return 1
}
ensure_jq() {
if discover_jq; then
return 0
fi
run_as_root() {
if [ "$(id -u)" -eq 0 ]; then
"$@"
elif command -v sudo >/dev/null 2>&1; then
sudo "$@"
else
return 1
fi
}
if command -v dnf >/dev/null 2>&1; then
run_as_root dnf install -y jq
elif command -v apt-get >/dev/null 2>&1; then
run_as_root apt-get update
run_as_root apt-get install -y jq
elif command -v apk >/dev/null 2>&1; then
run_as_root apk add --no-cache jq
elif command -v pacman >/dev/null 2>&1; then
run_as_root pacman -Sy --noconfirm jq
fi
discover_jq
}
ensure_curl() {
if command -v curl >/dev/null 2>&1; then
return 0
fi
run_as_root() {
if [ "$(id -u)" -eq 0 ]; then
"$@"
elif command -v sudo >/dev/null 2>&1; then
sudo "$@"
else
return 1
fi
}
if command -v dnf >/dev/null 2>&1; then
run_as_root dnf install -y curl
elif command -v apt-get >/dev/null 2>&1; then
run_as_root apt-get update
run_as_root apt-get install -y curl
elif command -v apk >/dev/null 2>&1; then
run_as_root apk add --no-cache curl
elif command -v pacman >/dev/null 2>&1; then
run_as_root pacman -Sy --noconfirm curl
fi
command -v curl >/dev/null 2>&1
}
ensure_jq || { echo "jq not found; install jq on the runner (dnf install -y jq)."; exit 1; }
ensure_curl || { echo "curl not found; install curl on the runner (dnf install -y curl)."; exit 1; }
curl_tls_args=()
if [ "${FORGEJO_CURL_INSECURE:-false}" = "true" ]; then
curl_tls_args+=(--insecure)
fi
api_base="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases"
payload_file="$(mktemp)"
response_file="$(mktemp)"
commit_logs=""
if [ -n "${COMMIT_LOG_FILE:-}" ] && [ -f "${COMMIT_LOG_FILE}" ]; then
commit_logs="$(cat "${COMMIT_LOG_FILE}")"
fi
commit_logs="$(printf '%s' "$commit_logs" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
if [ -z "$commit_logs" ]; then
commit_logs=$'##### Other\n\n- Initial release\n'
fi
python3 - <<'PY' > "$payload_file"
import json
import os
import textwrap
full_changelog=""
if [ -n "${PREV_TAG_NAME:-}" ]; then
full_changelog="**Full Changelog**: [${REPOSITORY_NAME}@${PREV_TAG_NAME}...${VERSION_TAG}](${REPO_URL}/compare/${PREV_TAG_NAME}...${VERSION_TAG})"
fi
version_tag = os.environ["VERSION_TAG"]
previous_tag = os.environ.get("PREV_TAG_NAME", "").strip()
commit_logs = os.environ.get("COMMIT_LOGS", "").strip() or "- Initial release"
repo_url = os.environ["REPO_URL"]
repository_name = os.environ["REPOSITORY_NAME"]
release_body_file="$(mktemp)"
{
printf '%s\n' \
"#### What's Changed" \
"" \
"${commit_logs}" \
""
if [ -n "${full_changelog}" ]; then
printf '%s\n' "${full_changelog}" ""
fi
printf '%s\n' \
"<!-->" \
"> [!TIP]" \
">" \
"> ### If you are unsure which version to download then go with \`Komikku-universal.apk\`"
} > "${release_body_file}"
if previous_tag:
full_changelog = (
f"**Full Changelog**: "
f"[{repository_name}@{previous_tag}...{version_tag}]"
f"({repo_url}/compare/{previous_tag}...{version_tag})"
)
else:
full_changelog = ""
# Build JSON — body from file only (jq --arg body "$huge" hits ARG_MAX).
"${JQ_BIN}" -n \
--arg tag "${VERSION_TAG}" \
--arg title "Komikku ${VERSION_DISPLAY}" \
--rawfile body "${release_body_file}" \
'{tag_name: $tag, name: $title, body: $body, draft: false, prerelease: false}' > "${payload_file}"
body = textwrap.dedent(
f"""\
#### What's Changed
##### New
tag_path_enc="$("${JQ_BIN}" -nr --arg t "${VERSION_TAG}" '$t|@uri')"
tag_get_url="${api_base}/tags/${tag_path_enc}"
echo "Forgejo release API: GET ${tag_get_url}"
##### Improve
status="$(curl "${curl_tls_args[@]}" -sS -o "$response_file" -w '%{http_code}' \
##### Fix
{commit_logs}
##### Based on
{full_changelog}
<!-->
> [!TIP]
>
> ### If you are unsure which version to download then go with `Komikku-{version_tag}.apk`
""",
)
print(
json.dumps(
{
"tag_name": version_tag,
"name": f"Komikku {version_tag}",
"body": body,
"draft": True,
"prerelease": False,
},
),
)
PY
status="$(curl -sS -o "$response_file" -w '%{http_code}' \
-H "Authorization: token ${FORGEJO_TOKEN}" \
"${tag_get_url}")"
"${api_base}/tags/${VERSION_TAG}")"
echo "release-by-tag GET HTTP status: ${status}"
if [ "${status}" != "200" ] && [ "${status}" != "404" ]; then
echo "Unexpected GET response body:"
cat "$response_file"
exit 1
fi
release_id=""
if [ "${status}" = "200" ]; then
release_id="$("${JQ_BIN}" -r '.id // empty' < "$response_file")"
if [ -z "${release_id}" ] || [ "${release_id}" = "null" ]; then
echo "GET returned 200 but missing release id; body:"
cat "$response_file"
exit 1
fi
echo "PATCH existing release id=${release_id}"
patch_status="$(curl "${curl_tls_args[@]}" -sS -o "$response_file" -w '%{http_code}' \
if [ "$status" = "200" ]; then
release_id="$(python3 -c 'import json, sys; print(json.load(sys.stdin)["id"])' < "$response_file")"
curl -fsS \
-X PATCH \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-H 'Content-Type: application/json' \
--data @"$payload_file" \
"${api_base}/${release_id}")"
echo "PATCH HTTP status: ${patch_status}"
if [ "${patch_status}" != "200" ]; then
echo "PATCH response body:"
cat "$response_file"
exit 1
fi
elif [ "${status}" = "404" ]; then
echo "POST new release for tag ${VERSION_TAG}"
post_status="$(curl "${curl_tls_args[@]}" -sS -o "$response_file" -w '%{http_code}' \
"${api_base}/${release_id}" \
> "$response_file"
elif [ "$status" = "404" ]; then
curl -fsS \
-X POST \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-H 'Content-Type: application/json' \
--data @"$payload_file" \
"${api_base}")"
echo "POST HTTP status: ${post_status}"
if [ "${post_status}" != "201" ] && [ "${post_status}" != "200" ]; then
echo "POST response body:"
cat "$response_file"
exit 1
fi
release_id="$("${JQ_BIN}" -r '.id // empty' < "$response_file")"
if [ -z "${release_id}" ] || [ "${release_id}" = "null" ]; then
echo "POST succeeded but missing release id; body:"
cat "$response_file"
exit 1
fi
"${api_base}" \
> "$response_file"
release_id="$(python3 -c 'import json, sys; print(json.load(sys.stdin)["id"])' < "$response_file")"
else
cat "$response_file"
exit 1
fi
echo "RELEASE_ID=${release_id}" >> "$GITHUB_OUTPUT"
echo "RELEASE_ID=$release_id" >> "$GITHUB_OUTPUT"
- name: Upload release assets
shell: bash
env:
FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_ID: ${{ steps.release.outputs.RELEASE_ID }}
FORGEJO_CURL_INSECURE: "true"
run: |
set -euo pipefail
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PATH:-}"
discover_jq() {
if command -v jq >/dev/null 2>&1; then
JQ_BIN="$(command -v jq)"
export JQ_BIN
return 0
fi
for cand in /usr/bin/jq /usr/local/bin/jq /bin/jq; do
if [ -x "$cand" ]; then
JQ_BIN="$cand"
export JQ_BIN
return 0
fi
done
return 1
}
ensure_jq() {
if discover_jq; then
return 0
fi
run_as_root() {
if [ "$(id -u)" -eq 0 ]; then
"$@"
elif command -v sudo >/dev/null 2>&1; then
sudo "$@"
else
return 1
fi
}
if command -v dnf >/dev/null 2>&1; then
run_as_root dnf install -y jq
elif command -v apt-get >/dev/null 2>&1; then
run_as_root apt-get update
run_as_root apt-get install -y jq
elif command -v apk >/dev/null 2>&1; then
run_as_root apk add --no-cache jq
elif command -v pacman >/dev/null 2>&1; then
run_as_root pacman -Sy --noconfirm jq
fi
discover_jq
}
ensure_curl() {
if command -v curl >/dev/null 2>&1; then
return 0
fi
run_as_root() {
if [ "$(id -u)" -eq 0 ]; then
"$@"
elif command -v sudo >/dev/null 2>&1; then
sudo "$@"
else
return 1
fi
}
if command -v dnf >/dev/null 2>&1; then
run_as_root dnf install -y curl
elif command -v apt-get >/dev/null 2>&1; then
run_as_root apt-get update
run_as_root apt-get install -y curl
elif command -v apk >/dev/null 2>&1; then
run_as_root apk add --no-cache curl
elif command -v pacman >/dev/null 2>&1; then
run_as_root pacman -Sy --noconfirm curl
fi
command -v curl >/dev/null 2>&1
}
ensure_jq || { echo "jq not found; install jq on the runner (dnf install -y jq)."; exit 1; }
ensure_curl || { echo "curl not found; install curl on the runner (dnf install -y curl)."; exit 1; }
curl_tls_args=()
if [ "${FORGEJO_CURL_INSECURE:-false}" = "true" ]; then
curl_tls_args+=(--insecure)
fi
if [ -z "${RELEASE_ID:-}" ] || [ "${RELEASE_ID}" = "null" ]; then
echo "RELEASE_ID missing or null — create/update release step likely failed."
exit 1
fi
release_api="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}"
assets_file="$(mktemp)"
err_body="$(mktemp)"
assets_status="$(curl "${curl_tls_args[@]}" -sS -o "$assets_file" -w '%{http_code}' \
curl -fsS \
-H "Authorization: token ${FORGEJO_TOKEN}" \
"${release_api}/assets")"
if [ "${assets_status}" != "200" ]; then
echo "List assets failed HTTP ${assets_status}:"
cat "$assets_file"
rm -f "$assets_file" "$err_body"
exit 1
fi
"${release_api}/assets" \
> "$assets_file"
for file in Komikku-*.apk Komikku-mapping-*.zip; do
[ -e "$file" ] || continue
if [ "${file#*-unsigned.apk}" != "${file}" ]; then
continue
fi
asset_name="$(basename "$file")"
asset_id="$("${JQ_BIN}" -r --arg n "${asset_name}" '(.[] | select(.name == $n) | .id) // empty' "$assets_file")"
asset_id="$(
python3 -c 'import json, sys; assets = json.load(open(sys.argv[1], encoding="utf-8")); name = sys.argv[2]; print(next((str(asset["id"]) for asset in assets if asset.get("name") == name), ""))' \
"$assets_file" \
"$asset_name"
)"
if [ -n "$asset_id" ] && [ "$asset_id" != "null" ]; then
del_status="$(curl "${curl_tls_args[@]}" -sS -o "$err_body" -w '%{http_code}' \
if [ -n "$asset_id" ]; then
curl -fsS \
-X DELETE \
-H "Authorization: token ${FORGEJO_TOKEN}" \
"${release_api}/assets/${asset_id}")"
if [ "${del_status}" != "204" ] && [ "${del_status}" != "200" ] && [ "${del_status}" != "404" ]; then
echo "DELETE old asset ${asset_name} failed HTTP ${del_status}:"
cat "$err_body"
rm -f "$assets_file" "$err_body"
exit 1
fi
"${release_api}/assets/${asset_id}" \
> /dev/null
fi
encoded_name="$("${JQ_BIN}" -nr --arg s "${asset_name}" '$s | @uri')"
encoded_name="$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1]))' "$asset_name")"
up_status="$(curl "${curl_tls_args[@]}" -sS -o "$err_body" -w '%{http_code}' \
curl -fsS \
-X POST \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-H 'Content-Type: application/octet-stream' \
--data-binary @"$file" \
"${release_api}/assets?name=${encoded_name}")"
if [ "${up_status}" != "201" ] && [ "${up_status}" != "200" ]; then
echo "Upload ${asset_name} failed HTTP ${up_status}:"
cat "$err_body"
rm -f "$assets_file" "$err_body"
exit 1
fi
echo "Uploaded ${asset_name} (HTTP ${up_status})"
"${release_api}/assets?name=${encoded_name}" \
> /dev/null
done
rm -f "$assets_file" "$err_body"

View file

@ -1,100 +0,0 @@
name: Upstream sync status
# Lightweight job: fetch upstream from GitHub, write status files, optional issue.
on:
schedule:
- cron: '0 6 * * 1'
workflow_dispatch:
permissions:
contents: write
issues: write
concurrency:
group: upstream-status
cancel-in-progress: true
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Checkout and check upstream
shell: bash
env:
FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BEHIND_WARN: '10'
run: |
set -euo pipefail
rm -rf .git
git init .
origin_url="${GITHUB_SERVER_URL#https://}"
origin_url="${origin_url#http://}"
git remote add origin "https://x-access-token:${FORGEJO_TOKEN}@${origin_url}/${GITHUB_REPOSITORY}.git"
git -c http.sslVerify=false fetch --prune origin "${GITHUB_REF_NAME}"
git checkout --force FETCH_HEAD
git remote add upstream https://github.com/komikku-app/komikku.git
chmod +x scripts/check-upstream.sh
./scripts/check-upstream.sh --write
behind="$(python3 -c 'import json; print(json.load(open("docs/upstream-status.json"))["behind"])')"
ahead="$(python3 -c 'import json; print(json.load(open("docs/upstream-status.json"))["ahead"])')"
status="$(python3 -c 'import json; print(json.load(open("docs/upstream-status.json"))["status"])')"
echo "BEHIND=$behind" >> "$GITHUB_ENV"
echo "AHEAD=$ahead" >> "$GITHUB_ENV"
echo "STATUS=$status" >> "$GITHUB_ENV"
- name: Commit status files
shell: bash
env:
FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
git config user.name "forgejo-actions[bot]"
git config user.email "forgejo-actions[bot]@users.noreply.local"
git add UPSTREAM_STATUS.md docs/upstream-status.json
if git diff --staged --quiet; then
echo "No upstream status changes."
exit 0
fi
git commit -m "chore(ci): update upstream sync status"
origin_url="${GITHUB_SERVER_URL#https://}"
origin_url="${origin_url#http://}"
git push "https://x-access-token:${FORGEJO_TOKEN}@${origin_url}/${GITHUB_REPOSITORY}.git" "HEAD:${GITHUB_REF_NAME}"
- name: Open issue when far behind
if: env.STATUS == 'behind'
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
title="Upstream sync: ${BEHIND} commits behind komikku-app/komikku"
body=$(cat <<EOF
The weekly upstream check found this fork is **${BEHIND} commits behind** upstream and **${AHEAD} commits ahead** with fork-specific changes.
See [UPSTREAM_STATUS.md](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/src/branch/${GITHUB_REF_NAME}/UPSTREAM_STATUS.md) and [docs/upstream-sync.md](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/src/branch/${GITHUB_REF_NAME}/docs/upstream-sync.md).
To merge upstream locally:
\`\`\`sh
git fetch upstream
git merge upstream/master
\`\`\`
EOF
)
api="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues?state=open&labels=upstream-sync"
existing="$(curl -fsSL -H "Authorization: token ${GH_TOKEN}" "$api" | python3 -c 'import json,sys; issues=json.load(sys.stdin); print(next((str(i["number"]) for i in issues if i.get("title")==sys.argv[1]), ""))' "$title")"
if [[ -n "$existing" ]]; then
curl -fsSL -X PATCH \
-H "Authorization: token ${GH_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(python3 -c 'import json,sys; print(json.dumps({"body": sys.argv[1]}))' "$body")" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${existing}"
echo "Updated issue #${existing}"
exit 0
fi
curl -fsSL -X POST \
-H "Authorization: token ${GH_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(python3 -c 'import json,sys; print(json.dumps({"title": sys.argv[1], "body": sys.argv[2], "labels": ["upstream-sync"]}))' "$title" "$body")" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues"
echo "Created upstream sync issue"

View file

@ -1 +0,0 @@
21

View file

@ -29,7 +29,7 @@ jobs:
echo "VERSION_TAG=$version_tag" >> $GITHUB_OUTPUT
- name: Clone Repository (${{ steps.get_tag.outputs.VERSION_TAG }})
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
@ -57,18 +57,18 @@ jobs:
needs: prepare-build
steps:
- name: Clone Repository (${{ needs.prepare-build.outputs.TAG_NAME }})
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Set up JDK
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version-file: .github/.java-version
java-version: 17
distribution: temurin
- name: Set up gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5
- name: Check code format
run: ./gradlew spotlessCheck
@ -101,7 +101,7 @@ jobs:
mv app/build/outputs/apk/${{ needs.prepare-build.outputs.ARTIFACTS_PREFIX }}-x86_64-benchmark-signed.apk Komikku-x86_64-${{ needs.prepare-build.outputs.TAG_NAME }}.apk
- name: Upload artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
path: "**/*.apk"
retention-days: 30

View file

@ -30,7 +30,7 @@ jobs:
steps:
- name: Clone repo
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
@ -53,7 +53,7 @@ jobs:
uses: tj-actions/branch-names@5250492686b253f06fa55861556d1027b067aeb5 # v9.0.2
- name: Invoke workflow in preview repo
uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
uses: benc-uk/workflow-dispatch@a54f9d194fed472732282ed1597dc4909e4b4080 # v1.3.0
with:
workflow: build_app.yml
repo: komikku-app/komikku-preview

View file

@ -43,7 +43,7 @@ jobs:
echo "VERSION_TAG=$version_tag" >> $GITHUB_OUTPUT
- name: Clone Repository (${{ steps.get_tag.outputs.VERSION_TAG }})
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
@ -119,14 +119,14 @@ jobs:
if: github.ref == 'refs/heads/master'
steps:
- name: Clone Repository (${{ needs.prepare-build.outputs.VERSION_TAG }} - ${{ needs.prepare-build.outputs.TAG_NAME }})
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Set up JDK
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version-file: .github/.java-version
java-version: 17
distribution: temurin
- name: Write google-services.json
@ -144,7 +144,7 @@ jobs:
write-mode: overwrite
- name: Set up gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5
- name: Check code format
run: ./gradlew spotlessCheck
@ -156,7 +156,7 @@ jobs:
run: ./gradlew testReleaseUnitTest
- name: Upload artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
path: "**/*.apk"
retention-days: 1
@ -168,7 +168,7 @@ jobs:
- build-app
steps:
- name: Download artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
merge-multiple: true
@ -195,7 +195,7 @@ jobs:
mv app/build/outputs/apk/${{ needs.prepare-build.outputs.ARTIFACTS_PREFIX }}-x86_64-preview-signed.apk Komikku-x86_64-${{ needs.prepare-build.outputs.TAG_NAME }}.apk
- name: Create release
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with:
tag_name: ${{ needs.prepare-build.outputs.TAG_NAME }}
name: Komikku Preview ${{ needs.prepare-build.outputs.TAG_NAME }} (${{ needs.prepare-build.outputs.VERSION_TAG }})

View file

@ -30,20 +30,20 @@ jobs:
steps:
- name: Clone repo
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Dependency Review
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
uses: actions/dependency-review-action@05fe4576374b728f0c523d6a13d64c25081e0803 # v4.8.3
- name: Validate Gradle Wrapper
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
uses: gradle/actions/wrapper-validation@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
- name: Set up JDK
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version-file: .github/.java-version
java-version: 17
distribution: temurin
- name: Write google-services.json
@ -63,7 +63,7 @@ jobs:
write-mode: overwrite
- name: Set up gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5
- name: Check code format
run: ./gradlew spotlessCheck
@ -82,7 +82,7 @@ jobs:
- name: Upload test report
if: steps.unit_tests.outcome == 'failure'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: test-report-${{ github.sha }}
path: app/build/reports/tests/testReleaseUnitTest
@ -119,13 +119,13 @@ jobs:
mv app/build/outputs/apk/preview/app-universal-preview${{ steps.signed_filename.outputs.SIGNED_TRAIL }}.apk Komikku-$version_tag-r$commit_count.apk
- name: Upload APK
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: Komikku-${{ steps.current_commit.outputs.VERSION_TAG }}-r${{ steps.current_commit.outputs.COMMIT_COUNT }}.apk
path: ./*.apk
- name: Upload mapping
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: mapping-${{ github.sha }}
path: app/build/outputs/mapping/preview

View file

@ -27,14 +27,14 @@ jobs:
steps:
- name: Clone repo
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Set up JDK
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version-file: .github/.java-version
java-version: 17
distribution: temurin
- name: Write google-services.json
@ -52,7 +52,7 @@ jobs:
write-mode: overwrite
- name: Set up gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5
- name: Check code format
run: ./gradlew spotlessCheck
@ -66,7 +66,7 @@ jobs:
- name: Upload test report
if: steps.unit_tests.outcome == 'failure'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: test-report-${{ github.sha }}
path: app/build/reports/tests/testReleaseUnitTest
@ -95,13 +95,13 @@ jobs:
mv app/build/outputs/apk/preview/app-universal-preview-signed.apk Komikku-$version_tag-r$commit_count.apk
- name: Upload APK
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: Komikku-${{ steps.current_commit.outputs.VERSION_TAG }}-r${{ steps.current_commit.outputs.COMMIT_COUNT }}.apk
path: ./*.apk
- name: Upload mapping
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: mapping-${{ github.sha }}
path: app/build/outputs/mapping/preview

View file

@ -31,7 +31,7 @@ jobs:
echo "VERSION_TAG=$version_tag" >> $GITHUB_OUTPUT
- name: Clone Repository (${{ steps.get_tag.outputs.VERSION_TAG }})
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
@ -87,14 +87,14 @@ jobs:
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Clone Repository (${{ needs.prepare-build.outputs.VERSION_TAG }})
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Set up JDK
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version-file: .github/.java-version
java-version: 17
distribution: temurin
- name: Write google-services.json
@ -112,7 +112,7 @@ jobs:
write-mode: overwrite
- name: Set up gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5
- name: Check code format
run: ./gradlew spotlessCheck
@ -124,13 +124,13 @@ jobs:
run: ./gradlew testReleaseUnitTest
- name: Upload artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
path: "**/*.apk"
retention-days: 1
- name: Upload mapping
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: mapping-${{ github.sha }}
path: app/build/outputs/mapping/release
@ -142,7 +142,7 @@ jobs:
- build-app
steps:
- name: Download artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
merge-multiple: true
@ -169,7 +169,7 @@ jobs:
mv app/build/outputs/apk/release/app-x86_64-release-unsigned-signed.apk Komikku-x86_64-${{ needs.prepare-build.outputs.VERSION_TAG }}.apk
- name: Create release
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with:
tag_name: ${{ needs.prepare-build.outputs.VERSION_TAG }}
name: Komikku ${{ needs.prepare-build.outputs.VERSION_TAG }}

View file

@ -13,14 +13,9 @@ jobs:
if: github.repository == 'komikku-app/komikku'
runs-on: 'ubuntu-24.04'
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Setup SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.CODEBERG_SSH }}" >> ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
- uses: pixta-dev/repository-mirroring-action@674e65a7d483ca28dafaacba0d07351bdcc8bd75 # v1.1.1
with:
target_repo_url: "git@codeberg.org:cuong-tran/komikku.git"

View file

@ -13,7 +13,7 @@ jobs:
if: github.event.pull_request.merged == true && github.repository == github.event.pull_request.head.repo.full_name
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Delete branch
run: |

View file

@ -15,7 +15,7 @@ jobs:
steps:
- name: Check PR and Add Label
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const prAuthor = context.payload.pull_request.user.login;

View file

@ -13,7 +13,7 @@ jobs:
steps:
- name: Update website on release
uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
uses: benc-uk/workflow-dispatch@a54f9d194fed472732282ed1597dc4909e4b4080 # v1.3.0
with:
workflow: Deploy
repo: komikku-app/komikku-app.github.io

218
AGENTS.md
View file

@ -1,218 +0,0 @@
# Komikku AI Agent Guide
Komikku is an Android manga reader (min SDK 26, target SDK 36, JVM 17 / Kotlin) forked from **Mihon** + **TachiyomiSY**. Stack: Jetpack Compose + Material3, Voyager navigation, SQLDelight, Injekt DI. `applicationId`: `app.komikku`.
---
## Mandatory rules for AI agents
**Read this section before every change.** These rules override shortcuts (e.g. copying nearby `MR` imports or only running `compileDebugKotlin`).
### Git
| Rule | Required behavior |
|------|-------------------|
| Branch | Create a **feature branch** for the task (`git checkout -b <type>/<short-description>`). |
| Commit | **OK** on a feature branch when work is ready. **Never** commit directly to `master` / `main` unless the user explicitly asks. |
| Push | **OK** to push the **current feature branch** when work is ready. **Never** push to `master` / `main` unless the user explicitly asks. |
Before `git push`, confirm the current branch is not `master` or `main` (`git branch --show-current`).
### Internationalization (strings)
| String kind | Module | Resource class | Base folder only |
|-------------|--------|----------------|------------------|
| Komikku-only (new features, KMK UI, library-update errors, WebDAV, Discord, etc.) | `i18n-kmk/` | **`KMR`** | `i18n-kmk/src/commonMain/moko-resources/base/` |
| Shared Mihon / upstream behavior | `i18n/` | **`MR`** | `i18n/src/commonMain/moko-resources/base/` |
| TachiyomiSY-only | `i18n-sy/` | **`SYMR`** | `i18n-sy/src/commonMain/moko-resources/base/` |
**Hard rules:**
- **Never** add Komikku-specific strings to `i18n/` or `i18n-sy/`.
- **Never** edit non-`base` locale `strings.xml` or `plurals.xml` files in `i18n-kmk/`, `i18n/`, or `i18n-sy/` (Weblate owns translations).
- Import: `import tachiyomi.i18n.kmk.KMR` for Komikku strings.
- If a change is inside `// KMK -->``// KMK <--` or adds Komikku-only behavior, default to **`KMR` + `i18n-kmk`**.
**Self-check before finishing:** `git diff` must not add new `<string name="…">` or `<plurals name="…">` entries under non-`base` locales in `i18n-kmk/src/`, `i18n/src/`, or `i18n-sy/src/`.
### Formatting & build verification
**“Build passes” is not enough.** After Kotlin/XML edits, run **in this order** before marking work complete:
```bash
./gradlew spotlessApply # fix formatting
./gradlew spotlessCheck # must pass (same as CI)
./gradlew assembleDebug # or :app:compileDebugKotlin for a faster compile-only check
```
- **Do not** skip `spotlessCheck` when verifying changes.
- If `spotlessCheck` fails, run `spotlessApply` and re-run `spotlessCheck`.
- On Cloud VM, export `ANDROID_HOME` and `JAVA_HOME` first (see [Cursor Cloud](#cursor-cloud-specific-instructions)).
---
## Module layout
| Module | Purpose |
|--------|---------|
| `app/` | UI (`eu.kanade.*`, `exh/`, `mihon/`), DI, workers, build variants |
| `domain/` | Use cases in `…/interactor/` (e.g. `GetManga`), models, repo interfaces |
| `data/` | SQLDelight DB, `*RepositoryImpl` (`tachiyomi.data.*`) |
| `core:common/` | Network (OkHttp), security, storage, shared utils |
| `core:archive/` | CBZ/archive reading with optional encryption |
| `core-metadata/` | Comic-info metadata parsing |
| `source-api/` / `source-local/` | Extension `Source` API + local source |
| `presentation-core/` | Shared Compose components |
| `presentation-widget/` | Home-screen Glance widget |
| `i18n/` | Mihon strings → `MR` (moko-resources) |
| `i18n-kmk/` | Komikku strings → `KMR` |
| `i18n-sy/` | TachiyomiSY strings → `SYMR` |
| `flagkit/` | Country-flag drawables |
| `telemetry/` | Firebase/Crashlytics (noop unless `-Pinclude-telemetry`) |
| `macrobenchmark/` | Macrobenchmark tests |
Dependency flow: `app``domain``source-api`; `data` implements `domain` repos.
Version catalogs: `gradle/libs.versions.toml`, `kotlinx.versions.toml`, `androidx.versions.toml`, `compose.versions.toml`, `sy.versions.toml`.
---
## Architecture
**DI** `uy.kohesive.injekt` (not Hilt). Register in `AppModule.kt`, `DomainModule.kt`, `KMKDomainModule.kt`, `SYDomainModule.kt` via `addSingleton` / `addSingletonFactory`. Resolve with `Injekt.get<T>()` or `injectLazy<T>()`.
**UI & navigation** [Voyager](https://voyager.adriel.cafe/): `Screen` in `eu.kanade.tachiyomi.ui.*`, composables in `eu.kanade.presentation.*`. Base type: `eu.kanade.presentation.util.Screen`. State via `rememberScreenModel { … }`; most models extend `StateScreenModel<State>` or bases like `SearchScreenModel`; some use plain `ScreenModel`. Prefer `screenModelScope` and `ioCoroutineScope`; use `launchIO` / `withIOContext` from `tachiyomi.core.common.util.lang`. `rememberCoroutineScope()` is fine in Compose; long-lived services may use their own `CoroutineScope`.
**Activities (not Voyager)** `MainActivity` (shell), `ReaderActivity` + `ReaderViewModel`, `WebViewActivity`, `UnlockActivity`, OAuth login activities, `DeepLinkActivity`. Reader: `ReaderActivity.newIntent(context, mangaId, chapterId)`. Web: both `WebViewScreen` (Voyager) and `WebViewActivity.newIntent(...)`.
Example: `DeepLinkScreen` + `DeepLinkScreenModel` in `app/src/main/java/eu/kanade/tachiyomi/ui/deeplink/`.
**Domain / data** One class per operation under `domain/…/interactor/` (verb names, not `*Interactor` suffix). Also `app/src/main/java/eu/kanade/domain/…/interactor/` for app-specific cases. Wire repos in `eu.kanade.domain.DomainModule.kt` (+ `KMKDomainModule`, `SYDomainModule`).
**Database** SQLDelight in `data/src/main/sqldelight/tachiyomi/` (`.sq` queries, `migrations/*.sqm`). After schema changes add a new `.sqm` and often `// KMK` blocks in `.sq` / mappers. Regenerate: `./gradlew :data:generateSqlDelightInterface` (or any compile that touches `:data`).
**App preference migrations** `app/src/main/java/mihon/core/migration/migrations/` (`mihon.core.migration.Migration`).
**Images** Coil 3 (`coil3.*`, `context.imageLoader`). No Glide/Picasso.
---
## Komikku-specific work
- **Strings:** see [Mandatory rules Internationalization](#mandatory-rules-for-ai-agents). Summary: Komikku → **`KMR`** / `i18n-kmk/…/base/` only.
- Do not edit locale `strings.xml` in `i18n/` or `i18n-sy/` except when syncing upstream; translations via [Weblate](https://hosted.weblate.org/engage/komikku-app/).
- Komikku code/DI: search `// KMK` (e.g. `KMKDomainModule`, `HideCategory`, library-update errors).
- Prefs: `eu.kanade.domain.*.service.*Preferences` (e.g. `SourcePreferences.relatedMangas()`).
**Examples (Komikku → `i18n-kmk`, not `i18n`):** library update error UI, sync-before-update messages, WebDAV/Discord settings, updater notifications, `mihon/feature/*` Komikku screens.
---
## Extensions & sources
- Catalog sources: installable APK extensions (not in this repo).
- In-repo: delegated sources and metadata in `exh/` (E-Hentai, NHentai, MangaDex, `exh/recs/`).
- `source-api`: `eu.kanade.tachiyomi.source.*` — avoid breaking extension ABI.
---
## Build & CI
Build types: `debug` (`.dev`), `release`, `releaseTest` (`.rt`), `foss` (`.foss`), `preview` (`.beta`, CI default), `benchmark`.
Gradle `-P` flags (`buildSrc/.../BuildConfig.kt`):
| Flag | Effect |
|------|--------|
| `include-telemetry` | Firebase Analytics + Crashlytics |
| `enable-updater` | In-app update checker |
| `disable-code-shrink` | Skip R8 minification |
| `include-dependency-info` | Dependency metadata in APK |
```bash
./gradlew spotlessApply # format (run before spotlessCheck)
./gradlew spotlessCheck # REQUIRED before considering work done (CI gate)
./gradlew assemblePreview # main CI/dev APK
./gradlew assemblePreview -Pinclude-telemetry -Penable-updater # full upstream CI build
./gradlew testReleaseUnitTest # CI unit tests (or ./gradlew test for all modules)
./gradlew installDebug # device install
./gradlew :data:generateSqlDelightInterface # after .sq / .sqm changes
```
**Agent verification checklist (minimum):** `spotlessApply``spotlessCheck``assembleDebug` (or `compileDebugKotlin` only if the user asked for a quick compile check—but still run Spotless).
JDK **17**.
---
## Fork-origin markers
Preserve inline blocks when editing:
```kotlin
// KMK --> … // KMK <-- Komikku
// SY --> … // SY <-- TachiyomiSY
// EXH --> … // EXH <-- E-Hentai / exh (existing); prefer KMK for new Komikku-only code
```
Package roots: `eu.kanade.tachiyomi.*` (legacy UI), `tachiyomi.*` (domain/data), `mihon.*` (Mihon upstream), `exh.*` (enhanced sources).
---
## Tests
- Unit tests: `domain/src/test/`; app: `app/src/test/.../MigratorTest.kt`. No broad UI test suite.
---
## Conventions
- **Logging** Prefer `xLogE()` / `xLog()` helpers from `exh.log` for Komikku code, Mihon uses `logcat { }` from `tachiyomi.core.common.util.system`. Avoid raw `android.util.Log`.
- **Formatting** Spotless + ktlint (`buildSrc/.../mihon.code.lint.gradle.kts`). Agents **must** run `spotlessApply` and `spotlessCheck` (see [Mandatory rules](#mandatory-rules-for-ai-agents)).
- **Fork edits** New Komikku features inside `// KMK` islands; keep `// SY` / `// EXH` blocks intact when merging upstream.
---
## Key files
- `App.kt` Injekt bootstrap, logging setup
- `MainActivity.kt` Voyager host
- `app/src/main/java/eu/kanade/tachiyomi/di/AppModule.kt` core DI
- `app/src/main/java/eu/kanade/domain/DomainModule.kt` domain interactors
- `buildSrc/.../BuildConfig.kt`, `AndroidConfig.kt` flags, SDK versions
- `app/build.gradle.kts`, `settings.gradle.kts`
---
## Cursor Cloud specific instructions
### Environment
The VM update script installs the Android SDK (platform 36, build-tools 35.0.1, platform-tools, cmdline-tools) into `/opt/android-sdk` and writes `local.properties` with `sdk.dir`. JDK 21 is pre-installed and works fine for compiling to JVM target 17. `ANDROID_HOME`, `JAVA_HOME`, and `PATH` are set in `~/.bashrc`.
### Running key commands
All Gradle commands require the environment variables set above. Export them before invoking `./gradlew` if running in a fresh shell:
```bash
export ANDROID_HOME=/opt/android-sdk
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64
```
| Task | Command |
|------|---------|
| **Required format fix** | `./gradlew spotlessApply` (run first after code edits) |
| **Required format gate** | `./gradlew spotlessCheck` (must pass before task is done) |
| Debug APK build | `./gradlew assembleDebug` |
| Preview APK build (CI) | `./gradlew assemblePreview` |
| Unit tests (CI) | `./gradlew testReleaseUnitTest` |
| All module tests | `./gradlew test` |
| SQLDelight codegen | `./gradlew :data:generateSqlDelightInterface` |
### Gotchas
- First Gradle build downloads ~1 GB of dependencies; subsequent builds use the Gradle cache and are much faster.
- `local.properties` is `.gitignore`d — it must be recreated if missing (the update script handles this).
- No Android emulator or device is available on the Cloud VM, so `installDebug` will fail. Build verification is done via `assembleDebug`.
- `google-services.json` and `client_secrets.json` are not present (CI secrets); builds without `-Pinclude-telemetry` succeed without them.
- Gradle daemon may use significant memory (`-Xmx4g` in `gradle.properties`). If OOM occurs, kill and restart the daemon with `./gradlew --stop`.

View file

@ -12,54 +12,9 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
## [Unreleased]
### Added
- Add support for `tachiyomix` extension index format ([@AntsyLich](https://github.com/AntsyLich)) ([#3349](https://github.com/mihonapp/mihon/pull/3349))
### Changed
- Change all reference of extension repo to extension store ([@AntsyLich](https://github.com/AntsyLich)) ([#3349](https://github.com/mihonapp/mihon/pull/3349))
- Change the term "Obsolete" to "Orphaned" for extensions ([@AntsyLich](https://github.com/AntsyLich)) ([#3383](https://github.com/mihonapp/mihon/pull/3383))
### Fixed
- Add missing `outlineVariant` color to Nord theme ([@CompileConnected](https://github.com/CompileConnected)) ([#3184](https://github.com/mihonapp/mihon/pull/3184))
## [v0.19.9] - 2026-04-11
### Fixed
- Regression with installing/updating extension ([@AntsyLich](https://github.com/AntsyLich))
## [v0.19.8] - 2026-04-11
### Added
- Add dedicated "Support Us" screen ([@AntsyLich](https://github.com/AntsyLich)) ([#3200](https://github.com/mihonapp/mihon/pull/3200))
- Add a one time popup asking for donation from long time users ([@AntsyLich](https://github.com/AntsyLich)) ([#3203](https://github.com/mihonapp/mihon/pull/3203))
### Improved
- Show informative error when trying to add unapproved titles to list on MAL ([@MajorTanya](https://github.com/MajorTanya)) ([#3155](https://github.com/mihonapp/mihon/pull/3155))
## [v0.19.7] - 2026-03-23
Same as v0.19.6
## [v0.19.6] - 2026-03-23
### Fixed
- Fix app crashing when trying to add extension repo with existing signature ([@AntsyLich](https://github.com/AntsyLich)) ([`cb0e329`](https://github.com/mihonapp/mihon/commit/cb0e329))
- Potentially fix 'database is locked' crash ([@AntsyLich](https://github.com/AntsyLich)) ([`f8e82b9`](https://github.com/mihonapp/mihon/commit/f8e82b9))
- Fix occasional crash when mass installing/uninstalling extension using `PackageManager` ([@AntsyLich](https://github.com/AntsyLich)) ([`42daa3f`](https://github.com/mihonapp/mihon/commit/42daa3f))
- Fix app crash on startup on some Android TV ([@AntsyLich](https://github.com/AntsyLich)) ([`b40f1d3`](https://github.com/mihonapp/mihon/commit/b40f1d3))
## [v0.19.5] - 2026-03-20
### Changed
- Retry in reader now redownloads image ([@AntsyLich](https://github.com/AntsyLich)) ([#3089](https://github.com/mihonapp/mihon/pull/3089))
### Fixed
- Fix performance regression introduced in v0.19.4 ([@AntsyLich](https://github.com/AntsyLich)) ([#3082](https://github.com/mihonapp/mihon/pull/3082))
- Fix duplicate key crash in duplicate detection ([@leodyversemilla07](https://github.com/leodyversemilla07)) ([#3040](https://github.com/mihonapp/mihon/pull/3040))
- Fix MangaUpdates HTTP 4XX errors ([@leodyversemilla07](https://github.com/leodyversemilla07)) ([#3021](https://github.com/mihonapp/mihon/pull/3021))
- Fix WebView JavaScript dialogs popup after screen is closed ([@leodyversemilla07](https://github.com/leodyversemilla07)) ([#3041](https://github.com/mihonapp/mihon/pull/3041))
- Fix extension actions disappearing after installing and uninstalling in same session ([@leodyversemilla07](https://github.com/leodyversemilla07)) ([#3049](https://github.com/mihonapp/mihon/pull/3049))
## [v0.19.4] - 2026-02-25
### Added
- Automatically remove downloads on Suwayomi after reading, configurable via extension settings ([@cpiber](https://github.com/cpiber)) ([#2673](https://github.com/mihonapp/mihon/pull/2673))
- Display author & artist name in MAL search results ([@MajorTanya](https://github.com/MajorTanya)) ([#2833](https://github.com/mihonapp/mihon/pull/2833))
- Add filter options to Updates tab ([@MajorTanya](https://github.com/MajorTanya)) ([#2851](https://github.com/mihonapp/mihon/pull/2851))
- Add bookmarked chapters to chapter download options ([@NarwhalHorns](https://github.com/NarwhalHorns)) ([#2891](https://github.com/mihonapp/mihon/pull/2891))
- Add `src:` prefix to search the library by source ID ([@MajorTanya](https://github.com/MajorTanya)) ([#2927](https://github.com/mihonapp/mihon/pull/2927))
- Add `src:local` as a way to search for Local Source entries ([@MajorTanya](https://github.com/MajorTanya)) ([#2928](https://github.com/mihonapp/mihon/pull/2928))
@ -67,16 +22,10 @@ Same as v0.19.6
- Minimize memory usage by reducing in-memory cover cache size ([@Lolle2000la](https://github.com/Lolle2000la)) ([#2266](https://github.com/mihonapp/mihon/pull/2266))
- Optimize MAL search queries ([@MajorTanya](https://github.com/MajorTanya)) ([#2832](https://github.com/mihonapp/mihon/pull/2832))
- Reword download reindexing message to avoid confusion ([@MajorTanya](https://github.com/MajorTanya)) ([#2874](https://github.com/mihonapp/mihon/pull/2874))
- Rework internals for better performance ([@Lolle2000la](https://github.com/Lolle2000la)) ([#2955](https://github.com/mihonapp/mihon/pull/2955))
- Optimize tracked library filter ([@NarwhalHorns](https://github.com/NarwhalHorns)) ([#2977](https://github.com/mihonapp/mihon/pull/2977))
- Utilize tracker for library duplicate detection ([@NarwhalHorns](https://github.com/NarwhalHorns)) ([#2977](https://github.com/mihonapp/mihon/pull/2978))
### Changed
- Update tracker icons ([@AntsyLich](https://github.com/AntsyLich)) ([#2773](https://github.com/mihonapp/mihon/pull/2773))
- Add a small increment to chapter number before comparison to fix progress sync issues for Suwayomi ([@cpiber](https://github.com/cpiber)) ([#2657](https://github.com/mihonapp/mihon/pull/2675))
- Add all pages of adjacent chapters in the UI instead of only the first or last three ([@AntsyLich](https://github.com/AntsyLich)) ([#2995](https://github.com/mihonapp/mihon/pull/2995))
- Going back now first clears search query on browse extension tab ([@cuong-tran](https://github.com/cuong-tran)) ([#2906](https://github.com/mihonapp/mihon/pull/2906))
- Automatic library updates now run even when connected to a VPN ([@AntsyLich](https://github.com/AntsyLich)) ([#2773](https://github.com/mihonapp/mihon/pull/2773))
### Fixed
- Fix reader tap zones triggering after scrolling is stopped by tapping ([@NGB-Was-Taken](https://github.com/NGB-Was-Taken)) ([#2680](https://github.com/mihonapp/mihon/pull/2680))
@ -85,10 +34,6 @@ Same as v0.19.6
- Fix reader not saving read duration when changing chapter ([@AntsyLich](https://github.com/AntsyLich), [@KotlinHero](https://github.com/KotlinHero)) ([#2784](https://github.com/mihonapp/mihon/pull/2784))
- Fix pre-1970 upload date display in chapter list ([@MajorTanya](https://github.com/MajorTanya)) ([#2779](https://github.com/mihonapp/mihon/pull/2779))
- Fix crash when trying to install/update extensions while shizuku is not running ([@NGB-Was-Taken](https://github.com/NGB-Was-Taken)) ([#2837](https://github.com/mihonapp/mihon/pull/2837))
- Fix Add Repo input not taking up the full dialog width ([@cuong-tran](https://github.com/cuong-tran)) ([#2816](https://github.com/mihonapp/mihon/pull/2816))
- Fix migration's selected sources order not preserved ([@AntsyLich](https://github.com/AntsyLich)) ([#2773](https://github.com/mihonapp/mihon/pull/2993))
- Fix migration dialog not showing for consecutive prompts from the same screen ([@AntsyLich](https://github.com/AntsyLich)) ([#2773](https://github.com/mihonapp/mihon/pull/2994))
- Fix extension install/update stuck at pending ([@AntsyLich](https://github.com/AntsyLich)) ([#3000](https://github.com/mihonapp/mihon/pull/3000))
### Other
- Enable logcat logging on stable and debug builds without enabling verbose logging ([@NGB-Was-Taken](https://github.com/NGB-Was-Taken)) ([#2836](https://github.com/mihonapp/mihon/pull/2836))

View file

@ -24,10 +24,6 @@
*Requires Android 8.0 or higher.*
Self-hosted Forgejo releases (including [Obtainium](https://github.com/ImranR98/Obtainium) install) are documented in [docs/forgejo-release-runner.md](docs/forgejo-release-runner.md).
**Fork upstream status:** see [UPSTREAM_STATUS.md](UPSTREAM_STATUS.md) (updated weekly; manual check: `./scripts/check-upstream.sh`). Merge guide: [docs/upstream-sync.md](docs/upstream-sync.md).
[![Sponsor me on GitHub](https://custom-icon-badges.demolab.com/badge/-Sponsor-ea4aaa?style=for-the-badge&logo=heart&logoColor=white)](https://github.com/sponsors/cuong-tran "Sponsor me on GitHub")
<div align="left">
@ -63,7 +59,7 @@ A free and open source manga reader which is based off TachiyomiSY & Mihon/Tachi
- Long-click to add/remove single entry to/from library, everywhere.
- Docking Read/Resume button to left/right.
- In-app progress banner shows Library syncing / Backup restoring / Library updating progress.
- `Series ETA` / reading time estimate in the reader, with optional persistence across sessions; library time-left badges use the same tuning. See [docs/reading-eta.md](docs/reading-eta.md).
- `Series ETA` / reading time estimate in the reader, with optional persistence across sessions.
- Auto-install app update.
- Configurable interval to refresh entries from downloaded storage.
- Forked from SY so everything from SY.
@ -126,6 +122,7 @@ Custom sources:
Additional features for some extensions, features include custom description, opening in app, batch add to library, and a bunch of other things based on the source:
* 8Muses (EroMuse)
* HBrowse
* Mangadex
* NHentai
* Puruin

View file

@ -1,48 +0,0 @@
# Upstream sync status
> Auto-generated by [`scripts/check-upstream.sh`](scripts/check-upstream.sh). Last check: **2026-07-10 07:56 UTC**.
| | |
|---|---|
| Status | 🔴 **Behind upstream** |
| Behind upstream | **73** commits on [`upstream/master`](https://github.com/komikku-app/komikku.git) |
| Ahead of upstream | **66** fork-only commits on `main` |
| Merge base | `bdf81596e9` |
| Fork HEAD | `b299a9ce81` |
| Upstream HEAD | `87a173929b` |
## Action recommended
This fork is **73 commits behind** [komikku-app/komikku](https://github.com/komikku-app/komikku.git). Consider merging upstream soon.
See [docs/upstream-sync.md](docs/upstream-sync.md) for the manual sync procedure.
## Recent upstream commits not in fork
```
87a173929b Adapt network implementation for TachiyomiX 1.6 (mihonapp/mihon#3248)
4ae732a17b fix(deps): Matching coroutines version
eb84ab8439 Ensure old extension store is removed if index url changes (mihonapp/mihon#3408)
5e809fefd4 Add auto migration support from legacy extension store index (mihonapp/mihon#3398)
1763d947df chore(sync): migrate sync preference keys (#1747)
ce6a0ea7fa fix(extension-store): Migrate to extension store during restore/sync legacy backup (#1746)
da57b3db07 Change the term "Obsolete" to "Orphaned" for extensions (mihonapp/mihon#3383)
da7ee11bb3 Change extension repo to extension store and add support for newer extension index format (#1744)
21c1b85796 Add informative error for unapproved MAL titles (mihonapp/mihon#3155)
0f556ea9b5 Address bundleOf deprecation (mihonapp/mihon#3073)
```
## Recent fork-only commits
```
b299a9ce81 docs(reading-eta): document unified limits, long chapters, and settings
f178a79195 feat(reading-eta): unify library and reader limits; make caps tunable
e0f4262407 ci(forgejo): verify signed APKs with unzip + aapt before upload
6316b85a09 fix(release): tolerate Forgejo null body/assets in release JSON
f91af6fc72 ci(forgejo): shorten release notes to since-last-tag + cap
c15d280cb4 feat(eta): cross-manga page-rate priors for reading time left
07019175d1 fix(eta): tame reading-time estimates (idle + cumulative history)
9ce3f0ba29 ci(forgejo): group release notes by prefix and fork vs merges
7fab10c8ef CI: build release JSON with jq --rawfile (avoid ARG_MAX on large bodies
e89f342881 CI: fix release step env — multiline COMMIT_LOGS broke act_runner
```

View file

@ -28,7 +28,7 @@ android {
defaultConfig {
applicationId = "app.komikku"
versionCode = 79
versionCode = 78
versionName = "1.13.6"
buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"")

View file

@ -307,5 +307,3 @@
-dontwarn org.ietf.jgss.GSSManager
-dontwarn org.ietf.jgss.GSSName
-dontwarn org.ietf.jgss.Oid
-dontwarn com.google.re2j.Matcher
-dontwarn com.google.re2j.Pattern

View file

@ -73,8 +73,8 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Legacy deeplink to add extension store -->
<intent-filter android:label="@string/action_addExtensionStore">
<!-- Deep link to add repos -->
<intent-filter android:label="@string/action_add_repo">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
@ -84,17 +84,6 @@
<data android:host="add-repo" />
</intent-filter>
<!-- Deeplink to add extension store -->
<intent-filter android:label="@string/action_addExtensionStore">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="mihon" />
<data android:host="extension-store" />
</intent-filter>
<!-- Open backup files -->
<intent-filter android:label="@string/pref_restore_backup">
<action android:name="android.intent.action.VIEW" />
@ -252,7 +241,7 @@
<service
android:name=".extension.util.ExtensionInstallService"
android:exported="false"
android:foregroundServiceType="dataSync" />
android:foregroundServiceType="shortService" />
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
@ -344,6 +333,22 @@
<data android:pathPattern="/g/..*" />
</intent-filter>
<!-- Tsumino -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:scheme="http" />
<data android:host="tsumino.com" />
<data android:host="www.tsumino.com" />
<data android:pathPattern="/Read/View/..*" />
<data android:pathPattern="/Book/Info/..*" />
</intent-filter>
<!-- Pururin -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
@ -358,6 +363,21 @@
<data android:pathPattern="/gallery/..*" />
</intent-filter>
<!-- HBrowse -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:scheme="http" />
<data android:host="hbrowse.com" />
<data android:host="www.hbrowse.com" />
<!--<data android:pathPattern="/gallery/..*" />-->
</intent-filter>
<!-- Mangadex -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />

View file

@ -16701,7 +16701,9 @@ HSPLeu/kanade/tachiyomi/source/online/all/MergedSource$special$$inlined$injectLa
PLeu/kanade/tachiyomi/source/online/all/MergedSource$special$$inlined$injectLazy$9;-><init>()V
Leu/kanade/tachiyomi/source/online/all/NHentai;
Leu/kanade/tachiyomi/source/online/english/EightMuses;
Leu/kanade/tachiyomi/source/online/english/HBrowse;
Leu/kanade/tachiyomi/source/online/english/Pururin;
Leu/kanade/tachiyomi/source/online/english/Tsumino;
Leu/kanade/tachiyomi/ui/base/activity/BaseActivity;
HSPLeu/kanade/tachiyomi/ui/base/activity/BaseActivity;-><init>()V
PLeu/kanade/tachiyomi/ui/base/activity/BaseActivity;-><init>()V

View file

@ -25,16 +25,16 @@ import eu.kanade.domain.track.interactor.AddTracks
import eu.kanade.domain.track.interactor.RefreshTracks
import eu.kanade.domain.track.interactor.SyncChapterProgressWithTrack
import eu.kanade.domain.track.interactor.TrackChapter
import mihon.data.extension.repository.ExtensionStoreRepositoryImpl
import mihon.data.extension.service.ExtensionStoreService
import mihon.data.repository.ExtensionRepoRepositoryImpl
import mihon.domain.chapter.interactor.FilterChaptersForDownload
import mihon.domain.chapter.interactor.GetChaptersForLibraryUpdateDownload
import mihon.domain.extension.interactor.AddExtensionStore
import mihon.domain.extension.interactor.GetExtensionStoreCountAsFlow
import mihon.domain.extension.interactor.GetExtensionStores
import mihon.domain.extension.interactor.RemoveExtensionStore
import mihon.domain.extension.interactor.UpdateExtensionStores
import mihon.domain.extension.repository.ExtensionStoreRepository
import mihon.domain.extensionrepo.interactor.CreateExtensionRepo
import mihon.domain.extensionrepo.interactor.DeleteExtensionRepo
import mihon.domain.extensionrepo.interactor.GetExtensionRepo
import mihon.domain.extensionrepo.interactor.GetExtensionRepoCount
import mihon.domain.extensionrepo.interactor.ReplaceExtensionRepo
import mihon.domain.extensionrepo.interactor.UpdateExtensionRepo
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
import mihon.domain.extensionrepo.service.ExtensionRepoService
import mihon.domain.migration.usecases.MigrateMangaUseCase
import mihon.domain.upcoming.interactor.GetUpcomingManga
import tachiyomi.data.category.CategoryRepositoryImpl
@ -58,7 +58,6 @@ import tachiyomi.domain.category.interactor.SetMangaCategories
import tachiyomi.domain.category.interactor.SetSortModeForCategory
import tachiyomi.domain.category.interactor.UpdateCategory
import tachiyomi.domain.category.repository.CategoryRepository
import tachiyomi.domain.chapter.interactor.GetBookmarkedChaptersByMangaId
import tachiyomi.domain.chapter.interactor.GetChapter
import tachiyomi.domain.chapter.interactor.GetChapterByUrlAndMangaId
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
@ -66,11 +65,8 @@ import tachiyomi.domain.chapter.interactor.SetMangaDefaultChapterFlags
import tachiyomi.domain.chapter.interactor.ShouldUpdateDbChapter
import tachiyomi.domain.chapter.interactor.UpdateChapter
import tachiyomi.domain.chapter.repository.ChapterRepository
import tachiyomi.domain.history.interactor.GetCrossMangaPageRateHistorySamples
import tachiyomi.domain.history.interactor.GetHistory
import tachiyomi.domain.history.interactor.GetHistoryRevision
import tachiyomi.domain.history.interactor.GetNextChapters
import tachiyomi.domain.history.interactor.GetReadDurationEntriesByMangaIds
import tachiyomi.domain.history.interactor.GetTotalReadDuration
import tachiyomi.domain.history.interactor.RemoveHistory
import tachiyomi.domain.history.interactor.UpsertHistory
@ -165,7 +161,6 @@ class DomainModule : InjektModule {
addSingletonFactory<ChapterRepository> { ChapterRepositoryImpl(get()) }
addFactory { GetChapter(get()) }
addFactory { GetChaptersByMangaId(get()) }
addFactory { GetBookmarkedChaptersByMangaId(get(), get(), get()) }
addFactory { GetChapterByUrlAndMangaId(get()) }
addFactory { UpdateChapter(get()) }
addFactory { SetReadStatus(get(), get(), get(), get(), get()) }
@ -173,16 +168,12 @@ class DomainModule : InjektModule {
addFactory { SyncChaptersWithSource(get(), get(), get(), get(), get(), get(), get(), get(), get()) }
addFactory { GetAvailableScanlators(get()) }
addFactory { FilterChaptersForDownload(get(), get(), get(), get()) }
addFactory { GetChaptersForLibraryUpdateDownload(get(), get()) }
addSingletonFactory<HistoryRepository> { HistoryRepositoryImpl(get()) }
addFactory { GetHistory(get()) }
addFactory { GetHistoryRevision(get()) }
addFactory { UpsertHistory(get()) }
addFactory { RemoveHistory(get()) }
addFactory { GetTotalReadDuration(get()) }
addFactory { GetReadDurationEntriesByMangaIds(get()) }
addFactory { GetCrossMangaPageRateHistorySamples(get(), get()) }
addFactory { DeleteDownload(get(), get()) }
@ -206,14 +197,14 @@ class DomainModule : InjektModule {
addFactory { ToggleSourcePin(get()) }
addFactory { TrustExtension(get(), get()) }
addSingletonFactory { ExtensionStoreService(get(), get(), get()) }
addSingletonFactory<ExtensionStoreRepository> { ExtensionStoreRepositoryImpl(get(), get()) }
addFactory { AddExtensionStore(get()) }
addFactory { GetExtensionStoreCountAsFlow(get()) }
addFactory { GetExtensionStores(get()) }
addFactory { RemoveExtensionStore(get()) }
addFactory { UpdateExtensionStores(get()) }
addSingletonFactory<ExtensionRepoRepository> { ExtensionRepoRepositoryImpl(get()) }
addFactory { ExtensionRepoService(get(), get()) }
addFactory { GetExtensionRepo(get()) }
addFactory { GetExtensionRepoCount(get()) }
addFactory { CreateExtensionRepo(get(), get()) }
addFactory { DeleteExtensionRepo(get()) }
addFactory { ReplaceExtensionRepo(get()) }
addFactory { UpdateExtensionRepo(get(), get()) }
addFactory { ToggleIncognito(get()) }
addFactory { GetIncognitoState(get(), get(), get()) }
}

View file

@ -200,7 +200,7 @@ class SyncChaptersWithSource(
bookmark = chapter.chapterNumber in deletedBookmarkedChapterNumbers,
)
// Try to use the fetch date of the original entry to not pollute 'Updates' tab
// Try to to use the fetch date of the original entry to not pollute 'Updates' tab
deletedChapterNumberDateFetchMap[chapter.chapterNumber]?.let {
chapter = chapter.copy(dateFetch = it)
}

View file

@ -28,7 +28,11 @@ class GetExtensionLanguages(
) { enabledLanguage, availableExtensions ->
availableExtensions
.flatMap { ext ->
ext.sources.map { it.lang }
if (ext.sources.isEmpty()) {
listOf(ext.lang)
} else {
ext.sources.map { it.lang }
}
}
.distinct()
.sortedWith(

View file

@ -50,6 +50,9 @@ class GetExtensionsByType(
(showNsfwSources || !extension.isNsfw)
}
.flatMap { ext ->
if (ext.sources.isEmpty()) {
return@flatMap if (ext.lang in enabledLanguages) listOf(ext) else emptyList()
}
ext.sources.filter { it.lang in enabledLanguages }
.map {
ext.copy(

View file

@ -4,21 +4,21 @@ import android.content.pm.PackageInfo
import androidx.core.content.pm.PackageInfoCompat
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.tachiyomi.util.system.isDebugBuildType
import mihon.domain.extension.model.KOMIKKU_SIGNATURE
import mihon.domain.extension.repository.ExtensionStoreRepository
import mihon.domain.extensionrepo.interactor.CreateExtensionRepo
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
import tachiyomi.core.common.preference.getAndSet
class TrustExtension(
private val repository: ExtensionStoreRepository,
private val extensionRepoRepository: ExtensionRepoRepository,
private val preferences: SourcePreferences,
) {
suspend fun isTrusted(pkgInfo: PackageInfo, fingerprints: List<String>): Boolean {
// KMK -->
if (isDebugBuildType) return true
if (fingerprints.contains(KOMIKKU_SIGNATURE)) return true
if (fingerprints.contains(CreateExtensionRepo.KOMIKKU_SIGNATURE)) return true
// KMK <--
val trustedFingerprints = repository.getAll().map { it.signingKey }.toHashSet()
val trustedFingerprints = extensionRepoRepository.getAll().map { it.signingKeyFingerprint }.toHashSet()
val key = "${pkgInfo.packageName}:${PackageInfoCompat.getLongVersionCode(pkgInfo)}:${fingerprints.last()}"
return trustedFingerprints.any { fingerprints.contains(it) } || key in preferences.trustedExtensions().get()
}

View file

@ -10,8 +10,8 @@ import java.util.UUID
class SyncPreferences(
private val preferenceStore: PreferenceStore,
) {
fun clientHost() = preferenceStore.getString("connection_sync_client_host", "https://sync.tachiyomi.org")
fun clientAPIKey() = preferenceStore.getString("connection_sync_client_api_key", "")
fun clientHost() = preferenceStore.getString("sync_client_host", "https://sync.tachiyomi.org")
fun clientAPIKey() = preferenceStore.getString("sync_client_api_key", "")
fun lastSyncTimestamp() = preferenceStore.getLong(Preference.appStateKey("last_sync_timestamp"), 0L)
fun lastSyncEtag() = preferenceStore.getString("sync_etag", "")
@ -20,19 +20,19 @@ class SyncPreferences(
fun syncService() = preferenceStore.getInt("sync_service", 0)
// KMK -->
fun webDavUrl() = preferenceStore.getString("connection_webdav_url", "")
fun webDavUsername() = preferenceStore.getString("connection_webdav_username", "")
fun webDavPassword() = preferenceStore.getString("connection_webdav_password", "")
fun webDavFolder() = preferenceStore.getString("connection_webdav_folder", "komikku")
fun webDavUrl() = preferenceStore.getString("webdav_url", "")
fun webDavUsername() = preferenceStore.getString("webdav_username", "")
fun webDavPassword() = preferenceStore.getString("webdav_password", "")
fun webDavFolder() = preferenceStore.getString("webdav_folder", "komikku")
// KMK <--
fun googleDriveAccessToken() = preferenceStore.getString(
Preference.appStateKey("connection_google_drive_access_token"),
Preference.appStateKey("google_drive_access_token"),
"",
)
fun googleDriveRefreshToken() = preferenceStore.getString(
Preference.appStateKey("connection_google_drive_refresh_token"),
Preference.appStateKey("google_drive_refresh_token"),
"",
)
@ -55,39 +55,39 @@ class SyncPreferences(
fun getSyncSettings(): SyncSettings {
return SyncSettings(
libraryEntries = preferenceStore.getBoolean("sync_library_entries", true).get(),
categories = preferenceStore.getBoolean("sync_categories", true).get(),
chapters = preferenceStore.getBoolean("sync_chapters", true).get(),
tracking = preferenceStore.getBoolean("sync_tracking", true).get(),
history = preferenceStore.getBoolean("sync_history", true).get(),
appSettings = preferenceStore.getBoolean("sync_appSettings", true).get(),
extensionStores = preferenceStore.getBoolean("sync_extensionStores", true).get(),
sourceSettings = preferenceStore.getBoolean("sync_sourceSettings", true).get(),
privateSettings = preferenceStore.getBoolean("sync_privateSettings", true).get(),
libraryEntries = preferenceStore.getBoolean("library_entries", true).get(),
categories = preferenceStore.getBoolean("categories", true).get(),
chapters = preferenceStore.getBoolean("chapters", true).get(),
tracking = preferenceStore.getBoolean("tracking", true).get(),
history = preferenceStore.getBoolean("history", true).get(),
appSettings = preferenceStore.getBoolean("appSettings", true).get(),
extensionRepoSettings = preferenceStore.getBoolean("extensionRepoSettings", true).get(),
sourceSettings = preferenceStore.getBoolean("sourceSettings", true).get(),
privateSettings = preferenceStore.getBoolean("privateSettings", true).get(),
// SY -->
customInfo = preferenceStore.getBoolean("sync_customInfo", true).get(),
readEntries = preferenceStore.getBoolean("sync_readEntries", true).get(),
savedSearchesFeeds = preferenceStore.getBoolean("sync_savedSearchesFeeds", true).get(),
customInfo = preferenceStore.getBoolean("customInfo", true).get(),
readEntries = preferenceStore.getBoolean("readEntries", true).get(),
savedSearchesFeeds = preferenceStore.getBoolean("savedSearchesFeeds", true).get(),
// SY <--
)
}
fun setSyncSettings(syncSettings: SyncSettings) {
preferenceStore.getBoolean("sync_library_entries", true).set(syncSettings.libraryEntries)
preferenceStore.getBoolean("sync_categories", true).set(syncSettings.categories)
preferenceStore.getBoolean("sync_chapters", true).set(syncSettings.chapters)
preferenceStore.getBoolean("sync_tracking", true).set(syncSettings.tracking)
preferenceStore.getBoolean("sync_history", true).set(syncSettings.history)
preferenceStore.getBoolean("sync_appSettings", true).set(syncSettings.appSettings)
preferenceStore.getBoolean("sync_extensionStores", true).set(syncSettings.extensionStores)
preferenceStore.getBoolean("sync_sourceSettings", true).set(syncSettings.sourceSettings)
preferenceStore.getBoolean("sync_privateSettings", true).set(syncSettings.privateSettings)
preferenceStore.getBoolean("library_entries", true).set(syncSettings.libraryEntries)
preferenceStore.getBoolean("categories", true).set(syncSettings.categories)
preferenceStore.getBoolean("chapters", true).set(syncSettings.chapters)
preferenceStore.getBoolean("tracking", true).set(syncSettings.tracking)
preferenceStore.getBoolean("history", true).set(syncSettings.history)
preferenceStore.getBoolean("appSettings", true).set(syncSettings.appSettings)
preferenceStore.getBoolean("extensionRepoSettings", true).set(syncSettings.extensionRepoSettings)
preferenceStore.getBoolean("sourceSettings", true).set(syncSettings.sourceSettings)
preferenceStore.getBoolean("privateSettings", true).set(syncSettings.privateSettings)
// SY -->
preferenceStore.getBoolean("sync_customInfo", true).set(syncSettings.customInfo)
preferenceStore.getBoolean("sync_readEntries", true).set(syncSettings.readEntries)
preferenceStore.getBoolean("sync_savedSearchesFeeds", true).set(syncSettings.savedSearchesFeeds)
preferenceStore.getBoolean("customInfo", true).set(syncSettings.customInfo)
preferenceStore.getBoolean("readEntries", true).set(syncSettings.readEntries)
preferenceStore.getBoolean("savedSearchesFeeds", true).set(syncSettings.savedSearchesFeeds)
// SY <--
}

View file

@ -7,7 +7,7 @@ data class SyncSettings(
val tracking: Boolean = true,
val history: Boolean = true,
val appSettings: Boolean = true,
val extensionStores: Boolean = true,
val extensionRepoSettings: Boolean = true,
val sourceSettings: Boolean = true,
val privateSettings: Boolean = false,

View file

@ -86,12 +86,12 @@ fun ExtensionDetailsScreen(
val uriHandler = LocalUriHandler.current
val url = remember(state.extension) {
val regex = """https://raw.githubusercontent.com/(.+?)/(.+?)/.+""".toRegex()
regex.find(state.extension?.store?.indexUrl.orEmpty())
regex.find(state.extension?.repoUrl.orEmpty())
?.let {
val (user, repo) = it.destructured
"https://github.com/$user/$repo"
}
?: state.extension?.store?.indexUrl
?: state.extension?.repoUrl
}
Scaffold(
@ -270,17 +270,14 @@ private fun DetailsHeader(
if (extension is Extension.Installed) {
append("\n\n")
appendLine(
append(
"""
Update available: ${extension.hasUpdate}
Orphaned: ${extension.isObsolete}
Obsolete: ${extension.isObsolete}
Shared: ${extension.isShared}
Repository: ${extension.repoUrl}
""".trimIndent(),
)
val store = extension.store
if (store != null) {
append("Repository: ${store.indexUrl}")
}
}
}
context.copyToClipboard("Extension Debug information", extDebugInfo)

View file

@ -48,7 +48,7 @@ import eu.kanade.presentation.browse.components.BaseBrowseItem
import eu.kanade.presentation.browse.components.ExtensionIcon
import eu.kanade.presentation.components.WarningBanner
import eu.kanade.presentation.manga.components.DotSeparatorNoSpaceText
import eu.kanade.presentation.more.settings.screen.browse.ExtensionStoresScreen
import eu.kanade.presentation.more.settings.screen.browse.ExtensionReposScreen
import eu.kanade.presentation.util.animateItemFastScroll
import eu.kanade.presentation.util.rememberRequestPackageInstallsPermissionState
import eu.kanade.tachiyomi.extension.model.Extension
@ -58,8 +58,6 @@ import eu.kanade.tachiyomi.ui.browse.extension.ExtensionsScreenModel
import eu.kanade.tachiyomi.util.system.LocaleHelper
import eu.kanade.tachiyomi.util.system.launchRequestPackageInstallsPermission
import kotlinx.collections.immutable.persistentListOf
import mihon.domain.extension.model.ExtensionStore
import mihon.domain.extension.model.KOMIKKU_SIGNATURE
import tachiyomi.i18n.MR
import tachiyomi.i18n.kmk.KMR
import tachiyomi.i18n.sy.SYMR
@ -112,9 +110,9 @@ fun ExtensionScreen(
modifier = Modifier.padding(contentPadding),
actions = persistentListOf(
EmptyScreenAction(
stringRes = MR.strings.extensionStores,
stringRes = MR.strings.label_extension_repos,
icon = Icons.Outlined.Settings,
onClick = { navigator.push(ExtensionStoresScreen()) },
onClick = { navigator.push(ExtensionReposScreen()) },
),
),
)
@ -197,9 +195,9 @@ private fun ExtensionContent(
// KMK -->
KMR.strings.extensions_page_more -> {
{
Button(onClick = { navigator?.push(ExtensionStoresScreen()) }) {
Button(onClick = { navigator?.push(ExtensionReposScreen()) }) {
Text(
text = stringResource(MR.strings.action_addExtensionStore),
text = stringResource(MR.strings.action_add_repo),
style = LocalTextStyle.current.copy(
color = MaterialTheme.colorScheme.onPrimary,
),
@ -403,7 +401,7 @@ private fun ExtensionItemContent(
}
// KMK -->
Text(text = extension.storeName?.let { "@$it" } ?: "(?)")
Text(text = extension.repoName?.let { "@$it" } ?: "(?)")
// KMK <--
val warning = when {
@ -606,11 +604,11 @@ private fun ExtensionItemContentPreview() {
libVersion = 1.0,
isNsfw = true,
signatureHash = "900000",
storeName = "Repository",
repoName = "Repository",
sources = emptyList(),
apkUrl = "Test",
apkName = "Test",
iconUrl = "",
store = ExtensionStore("https://repo", "Komikku", "", KOMIKKU_SIGNATURE, ExtensionStore.Contact("", ""), false),
repoUrl = "",
)
val extInstalled = Extension.Installed(
name = "Tachiyomi",
@ -621,9 +619,9 @@ private fun ExtensionItemContentPreview() {
libVersion = 1.0,
isNsfw = true,
signatureHash = "900000",
storeName = "Repository",
repoName = "Repository",
sources = emptyList(),
store = ExtensionStore("https://repo", "Komikku", "", KOMIKKU_SIGNATURE, ExtensionStore.Contact("", ""), false),
repoUrl = "",
pkgFactory = null,
icon = null,
hasUpdate = false,
@ -640,12 +638,12 @@ private fun ExtensionItemContentPreview() {
libVersion = 1.0,
isNsfw = true,
signatureHash = "900000",
storeName = "Repository",
repoName = "Repository",
)
Column {
ExtensionItemContent(
extension = extAvail.copy(
storeName = "Repository extensions minion multiple languages various sources",
repoName = "Repository extensions minion multiple languages various sources",
),
installStep = InstallStep.Idle,
)

View file

@ -336,46 +336,47 @@ fun SourceFeedToolbar(
// KMK -->
actions = {
AppBarActions(
actions = persistentListOf<AppBar.AppBarAction>().builder().apply {
add(bulkSelectionButton(isRunning, toggleSelectionMode))
actions = persistentListOf<AppBar.AppBarAction>().builder()
.apply {
add(bulkSelectionButton(isRunning, toggleSelectionMode))
onWebViewClick?.let { func ->
add(
AppBar.Action(
title = stringResource(MR.strings.action_web_view),
onClick = { func() },
icon = Icons.Outlined.Public,
),
)
}
onWebViewClick?.let {
add(
AppBar.Action(
title = stringResource(MR.strings.action_web_view),
onClick = { onWebViewClick() },
icon = Icons.Outlined.Public,
),
)
}
// KMK -->
add(
AppBar.OverflowAction(
title = stringResource(MR.strings.pref_incognito_mode),
onClick = onToggleIncognito,
),
)
// KMK <--
onSortFeedClick?.let { func ->
// KMK -->
add(
AppBar.OverflowAction(
title = stringResource(KMR.strings.action_sort_feed),
onClick = { func() },
title = stringResource(MR.strings.pref_incognito_mode),
onClick = onToggleIncognito,
),
)
}
// KMK <--
onSourceSettingClick?.let { func ->
add(
AppBar.OverflowAction(
title = stringResource(MR.strings.label_settings),
onClick = { func() },
),
)
onSortFeedClick?.let {
add(
AppBar.OverflowAction(
title = stringResource(KMR.strings.action_sort_feed),
onClick = { onSortFeedClick() },
),
)
}
onSourceSettingClick?.let {
add(
AppBar.OverflowAction(
title = stringResource(MR.strings.label_settings),
onClick = { onSourceSettingClick() },
),
)
}
}
}
.build(),
)
},

View file

@ -209,7 +209,7 @@ fun BrowseSourceEHentaiListItem(
MangaCoverHide.Book(
modifier = Modifier
.fillMaxHeight(),
bgColor = bgColor ?: MaterialTheme.colorScheme.surface.takeIf { isSelected },
bgColor = bgColor ?: (MaterialTheme.colorScheme.surface.takeIf { isSelected }),
tint = onBgColor,
)
} else {
@ -219,7 +219,7 @@ fun BrowseSourceEHentaiListItem(
.fillMaxHeight(),
// KMK -->
alpha = if (isSelected) GRID_SELECTED_COVER_ALPHA else coverAlpha,
bgColor = bgColor ?: MaterialTheme.colorScheme.surface.takeIf { isSelected },
bgColor = bgColor ?: (MaterialTheme.colorScheme.surface.takeIf { isSelected }),
tint = onBgColor,
// KMK <--
data = coverData,

View file

@ -39,26 +39,27 @@ fun BrowseSourceSimpleToolbar(
var selectingDisplayMode by remember { mutableStateOf(false) }
// KMK -->
AppBarActions(
actions = persistentListOf<AppBar.AppBarAction>().builder().apply {
displayMode?.let { mode ->
add(
AppBar.Action(
title = stringResource(MR.strings.action_display_mode),
icon = if (mode == LibraryDisplayMode.List) {
Icons.AutoMirrored.Filled.ViewList
} else {
Icons.Filled.ViewModule
},
onClick = { selectingDisplayMode = true },
),
)
actions = persistentListOf<AppBar.AppBarAction>().builder()
.apply {
displayMode?.let {
add(
AppBar.Action(
title = stringResource(MR.strings.action_display_mode),
icon = if (displayMode == LibraryDisplayMode.List) {
Icons.AutoMirrored.Filled.ViewList
} else {
Icons.Filled.ViewModule
},
onClick = { selectingDisplayMode = true },
),
)
}
toggleSelectionMode?.let {
add(
bulkSelectionButton(isRunning, toggleSelectionMode),
)
}
}
toggleSelectionMode?.let { mode ->
add(
bulkSelectionButton(isRunning, mode),
)
}
}
.build(),
)
// KMK <--

View file

@ -67,6 +67,7 @@ fun Screen.BulkFavoriteDialogs(
AddDuplicateMangaDialog(
dialog = dialog,
navigator = navigator,
state = bulkFavoriteState,
onDismiss = bulkFavoriteScreenModel::dismissDialog,
stopRunning = bulkFavoriteScreenModel::stopRunning,
toggleSelectionMode = bulkFavoriteScreenModel::toggleSelectionMode,
@ -133,9 +134,10 @@ private fun Screen.ShowMigrateDialog(
private fun AddDuplicateMangaDialog(
dialog: Dialog.AddDuplicateManga,
navigator: Navigator?,
state: BulkFavoriteScreenModel.State,
onDismiss: () -> Unit,
stopRunning: () -> Unit,
toggleSelectionMode: (Boolean) -> Unit,
toggleSelectionMode: () -> Unit,
addFavorite: (Manga) -> Unit,
showMigrateDialog: (manga: Manga, duplicate: Manga) -> Unit,
) {
@ -145,7 +147,9 @@ private fun AddDuplicateMangaDialog(
duplicates = dialog.duplicates,
onDismissRequest = onDismiss,
onConfirm = {
toggleSelectionMode(false)
if (state.selectionMode) {
toggleSelectionMode()
}
addFavorite(dialog.manga)
},
onOpenManga = { navigator?.push(MangaScreen(it.id)) },

View file

@ -30,51 +30,53 @@ fun BulkSelectionToolbar(
titleContent = { Text(text = "$selectedCount") },
actions = {
AppBarActions(
actions = persistentListOf<AppBar.AppBarAction>().builder().apply {
if (onSelectAll != null) {
add(
AppBar.Action(
title = stringResource(MR.strings.action_select_all),
icon = Icons.Filled.SelectAll,
onClick = onSelectAll,
),
)
}
if (onReverseSelection != null) {
add(
AppBar.Action(
title = stringResource(MR.strings.action_select_inverse),
icon = Icons.Outlined.FlipToBack,
onClick = onReverseSelection,
),
)
}
if (isRunning) {
add(
AppBar.ActionCompose(
title = stringResource(KMR.strings.action_bulk_select),
) {
CircularProgressIndicator(
modifier = Modifier
.size(24.dp),
strokeWidth = 2.dp,
)
},
)
} else {
add(
AppBar.Action(
title = stringResource(MR.strings.add_to_library),
icon = Icons.Filled.Favorite,
onClick = {
if (selectedCount > 0) {
onChangeCategoryClick()
}
actions = persistentListOf<AppBar.AppBarAction>()
.builder()
.apply {
if (onSelectAll != null) {
add(
AppBar.Action(
title = stringResource(MR.strings.action_select_all),
icon = Icons.Filled.SelectAll,
onClick = onSelectAll,
),
)
}
if (onReverseSelection != null) {
add(
AppBar.Action(
title = stringResource(MR.strings.action_select_inverse),
icon = Icons.Outlined.FlipToBack,
onClick = onReverseSelection,
),
)
}
if (isRunning) {
add(
AppBar.ActionCompose(
title = stringResource(KMR.strings.action_bulk_select),
) {
CircularProgressIndicator(
modifier = Modifier
.size(24.dp),
strokeWidth = 2.dp,
)
},
),
)
}
}.build(),
)
} else {
add(
AppBar.Action(
title = stringResource(MR.strings.add_to_library),
icon = Icons.Filled.Favorite,
onClick = {
if (selectedCount > 0) {
onChangeCategoryClick()
}
},
),
)
}
}.build(),
)
},
isActionMode = true,

View file

@ -44,7 +44,6 @@ private fun DownloadDropdownMenuItems(
DownloadAction.NEXT_10_CHAPTERS to pluralStringResource(MR.plurals.download_amount, 10, 10),
DownloadAction.NEXT_25_CHAPTERS to pluralStringResource(MR.plurals.download_amount, 25, 25),
DownloadAction.UNREAD_CHAPTERS to stringResource(MR.strings.download_unread),
DownloadAction.BOOKMARKED_CHAPTERS to stringResource(MR.strings.download_bookmarked),
)
options.forEach { (downloadAction, string) ->

View file

@ -263,7 +263,6 @@ private fun ColumnScope.SortPage(
MR.strings.action_sort_chapter_fetch_date to LibrarySort.Type.ChapterFetchDate,
MR.strings.action_sort_date_added to LibrarySort.Type.DateAdded,
trackerMeanPair,
SYMR.strings.action_sort_estimated_time_left to LibrarySort.Type.EstimatedTimeLeft,
// SY -->
tagSortPair,
// SY <--
@ -366,10 +365,6 @@ private fun ColumnScope.DisplayPage(
label = stringResource(MR.strings.action_display_unread_badge),
pref = screenModel.libraryPreferences.unreadBadge(),
)
CheckboxItem(
label = stringResource(SYMR.strings.action_display_estimated_time_left_badge),
pref = screenModel.libraryPreferences.estimatedTimeLeftBadge(),
)
CheckboxItem(
label = stringResource(MR.strings.action_display_local_badge),
pref = screenModel.libraryPreferences.localBadge(),

View file

@ -105,7 +105,7 @@ fun MangaCompactGridItem(
MangaCoverHide.Book(
modifier = Modifier
.fillMaxWidth(),
bgColor = bgColor ?: MaterialTheme.colorScheme.surface.takeIf { isSelected },
bgColor = bgColor ?: (MaterialTheme.colorScheme.surface.takeIf { isSelected }),
tint = onBgColor,
)
} else {
@ -119,7 +119,7 @@ fun MangaCompactGridItem(
data = coverData,
// KMK -->
alpha = if (isSelected) GRID_SELECTED_COVER_ALPHA else coverAlpha,
bgColor = bgColor ?: MaterialTheme.colorScheme.surface.takeIf { isSelected },
bgColor = bgColor ?: (MaterialTheme.colorScheme.surface.takeIf { isSelected }),
tint = onBgColor,
// KMK <--
)
@ -241,7 +241,7 @@ fun MangaComfortableGridItem(
MangaCoverHide.Book(
modifier = Modifier
.fillMaxWidth(),
bgColor = bgColor ?: MaterialTheme.colorScheme.surface.takeIf { isSelected },
bgColor = bgColor ?: (MaterialTheme.colorScheme.surface.takeIf { isSelected }),
tint = onBgColor,
)
} else {
@ -255,7 +255,7 @@ fun MangaComfortableGridItem(
data = coverData,
// KMK -->
alpha = if (isSelected) GRID_SELECTED_COVER_ALPHA else coverAlpha,
bgColor = bgColor ?: MaterialTheme.colorScheme.surface.takeIf { isSelected },
bgColor = bgColor ?: (MaterialTheme.colorScheme.surface.takeIf { isSelected }),
tint = onBgColor,
onCoverLoaded = { _, result ->
val image = result.result.image
@ -274,7 +274,7 @@ fun MangaComfortableGridItem(
data = coverData,
// KMK -->
alpha = if (isSelected) GRID_SELECTED_COVER_ALPHA else coverAlpha,
bgColor = bgColor ?: MaterialTheme.colorScheme.surface.takeIf { isSelected },
bgColor = bgColor ?: (MaterialTheme.colorScheme.surface.takeIf { isSelected }),
tint = onBgColor,
onCoverLoaded = { _, result ->
val image = result.result.image
@ -461,7 +461,7 @@ fun MangaListItem(
MangaCoverHide.Square(
modifier = Modifier
.fillMaxHeight(),
bgColor = bgColor ?: MaterialTheme.colorScheme.surface.takeIf { isSelected },
bgColor = bgColor ?: (MaterialTheme.colorScheme.surface.takeIf { isSelected }),
tint = onBgColor,
)
} else {
@ -475,7 +475,7 @@ fun MangaListItem(
data = coverData,
// KMK -->
alpha = coverAlpha,
bgColor = bgColor ?: MaterialTheme.colorScheme.surface.takeIf { isSelected },
bgColor = bgColor ?: (MaterialTheme.colorScheme.surface.takeIf { isSelected }),
tint = onBgColor,
size = MangaCover.Size.Big,
// KMK <--

View file

@ -8,7 +8,6 @@ import androidx.compose.material.icons.outlined.Folder
import androidx.compose.material.icons.outlined.LocalLibrary
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
@ -19,13 +18,10 @@ import eu.kanade.domain.extension.interactor.GetExtensionLanguages.Companion.get
import eu.kanade.domain.source.model.icon
import eu.kanade.presentation.theme.TachiyomiPreviewTheme
import eu.kanade.tachiyomi.R
import eu.kanade.presentation.util.toDurationString
import tachiyomi.domain.source.model.Source
import tachiyomi.presentation.core.components.Badge
import tachiyomi.presentation.core.components.BadgeGroup
import tachiyomi.source.local.isLocal
import kotlin.time.DurationUnit
import kotlin.time.toDuration
@Composable
internal fun DownloadsBadge(count: Long) {
@ -45,22 +41,6 @@ internal fun UnreadBadge(count: Long) {
}
}
@Composable
internal fun EstimatedTimeLeftBadge(etaMillis: Long?) {
val normalizedMillis = etaMillis?.takeIf { it > 0L } ?: return
val context = LocalContext.current
val text = normalizedMillis
.toDuration(DurationUnit.MILLISECONDS)
.toDurationString(context, fallback = "")
.ifBlank { return }
Badge(
text = text,
color = MaterialTheme.colorScheme.secondary,
textColor = MaterialTheme.colorScheme.onSecondary,
)
}
@Composable
internal fun LanguageBadge(
isLocal: Boolean,
@ -141,7 +121,6 @@ private fun BadgePreview() {
BadgeGroup {
DownloadsBadge(count = 10)
UnreadBadge(count = 10)
EstimatedTimeLeftBadge(etaMillis = 4_500_000)
LanguageBadge(isLocal = true, sourceLanguage = "en")
LanguageBadge(isLocal = false, sourceLanguage = "en", useLangIcon = false)
LanguageBadge(isLocal = false, sourceLanguage = "vi")

View file

@ -49,7 +49,6 @@ internal fun LibraryComfortableGrid(
coverBadgeStart = {
DownloadsBadge(count = libraryItem.downloadCount)
UnreadBadge(count = libraryItem.unreadCount)
EstimatedTimeLeftBadge(etaMillis = libraryItem.estimatedTimeLeftMillis)
},
coverBadgeEnd = {
LanguageBadge(

View file

@ -47,7 +47,6 @@ internal fun LibraryCompactGrid(
coverBadgeStart = {
DownloadsBadge(count = libraryItem.downloadCount)
UnreadBadge(count = libraryItem.unreadCount)
EstimatedTimeLeftBadge(etaMillis = libraryItem.estimatedTimeLeftMillis)
},
coverBadgeEnd = {
LanguageBadge(

View file

@ -56,7 +56,6 @@ internal fun LibraryList(
badge = {
DownloadsBadge(count = libraryItem.downloadCount)
UnreadBadge(count = libraryItem.unreadCount)
EstimatedTimeLeftBadge(etaMillis = libraryItem.estimatedTimeLeftMillis)
LanguageBadge(
isLocal = libraryItem.isLocal,
sourceLanguage = libraryItem.sourceLanguage,

View file

@ -25,6 +25,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
@ -38,7 +39,6 @@ import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.libraryUpdateError.components.LibraryUpdateErrorUiModel
import eu.kanade.presentation.libraryUpdateError.components.libraryUpdateErrorUiItems
import eu.kanade.presentation.manga.components.Button
import eu.kanade.tachiyomi.ui.libraryUpdateError.LibraryUpdateErrorItem
@ -67,7 +67,7 @@ fun LibraryUpdateErrorScreen(
onInvertSelection: () -> Unit,
onErrorsDelete: () -> Unit,
onErrorDelete: (Long) -> Unit,
onErrorSelected: (LibraryUpdateErrorItem, Boolean, Boolean) -> Unit,
onErrorSelected: (LibraryUpdateErrorItem, Boolean, Boolean, Boolean) -> Unit,
navigateUp: () -> Unit,
) {
BackHandler(enabled = state.selectionMode, onBack = { onSelectAll(false) })
@ -75,33 +75,31 @@ fun LibraryUpdateErrorScreen(
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
val uiModels = remember(state) { state.getUiModel() }
val headerIndexes = remember(state) {
uiModels.withIndex()
.filter { it.value is LibraryUpdateErrorUiModel.Header }
.map { it.index }
val headerIndexes = remember { mutableStateOf<List<Int>>(emptyList()) }
LaunchedEffect(state) {
headerIndexes.value = state.getHeaderIndexes()
}
val enableScrollToTop by remember(listState) {
val enableScrollToTop by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}
val enableScrollToBottom by remember(listState) {
val enableScrollToBottom by remember {
derivedStateOf {
listState.canScrollForward
}
}
val enableScrollToPrevious by remember(headerIndexes, listState) {
val enableScrollToPrevious by remember {
derivedStateOf {
headerIndexes.any { it < listState.firstVisibleItemIndex }
headerIndexes.value.any { it < listState.firstVisibleItemIndex }
}
}
val enableScrollToNext by remember(headerIndexes, listState) {
val enableScrollToNext by remember {
derivedStateOf {
headerIndexes.any { it > listState.firstVisibleItemIndex }
headerIndexes.value.any { it > listState.firstVisibleItemIndex }
}
}
@ -143,7 +141,7 @@ fun LibraryUpdateErrorScreen(
scrollToPrevious = {
scope.launch {
listState.scrollToItem(
headerIndexes
state.getHeaderIndexes()
.filter { it < listState.firstVisibleItemIndex }
.maxOrNull() ?: 0,
)
@ -152,7 +150,7 @@ fun LibraryUpdateErrorScreen(
scrollToNext = {
scope.launch {
listState.scrollToItem(
headerIndexes
state.getHeaderIndexes()
.filter { it > listState.firstVisibleItemIndex }
.minOrNull() ?: 0,
)
@ -175,7 +173,7 @@ fun LibraryUpdateErrorScreen(
state = listState,
) {
libraryUpdateErrorUiItems(
uiModels = uiModels,
uiModels = state.getUiModel(),
selectionMode = state.selectionMode,
onErrorSelected = onErrorSelected,
onClick = onClick,

View file

@ -44,7 +44,7 @@ import tachiyomi.presentation.core.util.selectedBackground
internal fun LazyListScope.libraryUpdateErrorUiItems(
uiModels: List<LibraryUpdateErrorUiModel>,
selectionMode: Boolean,
onErrorSelected: (LibraryUpdateErrorItem, Boolean, Boolean) -> Unit,
onErrorSelected: (LibraryUpdateErrorItem, Boolean, Boolean, Boolean) -> Unit,
onClick: (LibraryUpdateErrorItem) -> Unit,
onClickCover: (LibraryUpdateErrorItem) -> Unit,
onDelete: (Long) -> Unit,
@ -81,6 +81,7 @@ internal fun LazyListScope.libraryUpdateErrorUiItems(
selectionMode -> onErrorSelected(
libraryUpdateErrorItem,
!libraryUpdateErrorItem.selected,
true,
false,
)
@ -92,6 +93,7 @@ internal fun LazyListScope.libraryUpdateErrorUiItems(
libraryUpdateErrorItem,
!libraryUpdateErrorItem.selected,
true,
true,
)
},
onClickCover = { onClickCover(libraryUpdateErrorItem) }.takeIf { !selectionMode },
@ -142,8 +144,8 @@ private fun LibraryUpdateErrorUiItem(
.combinedClickable(
onClick = onClick,
onLongClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onLongClick()
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
},
)
.padding(horizontal = MaterialTheme.padding.medium),

View file

@ -94,7 +94,9 @@ import eu.kanade.tachiyomi.source.online.all.Lanraragi
import eu.kanade.tachiyomi.source.online.all.MangaDex
import eu.kanade.tachiyomi.source.online.all.NHentai
import eu.kanade.tachiyomi.source.online.english.EightMuses
import eu.kanade.tachiyomi.source.online.english.HBrowse
import eu.kanade.tachiyomi.source.online.english.Pururin
import eu.kanade.tachiyomi.source.online.english.Tsumino
import eu.kanade.tachiyomi.ui.manga.ChapterList
import eu.kanade.tachiyomi.ui.manga.MangaScreenModel
import eu.kanade.tachiyomi.ui.manga.MergedMangaData
@ -106,10 +108,12 @@ import exh.source.getMainSource
import exh.source.isEhBasedManga
import exh.ui.metadata.adapters.EHentaiDescription
import exh.ui.metadata.adapters.EightMusesDescription
import exh.ui.metadata.adapters.HBrowseDescription
import exh.ui.metadata.adapters.LanraragiDescription
import exh.ui.metadata.adapters.MangaDexDescription
import exh.ui.metadata.adapters.NHentaiDescription
import exh.ui.metadata.adapters.PururinDescription
import exh.ui.metadata.adapters.TsuminoDescription
import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.chapter.service.missingChaptersCount
import tachiyomi.domain.library.service.LibraryPreferences
@ -190,7 +194,7 @@ fun MangaScreen(
onChapterSwipe: (ChapterList.Item, LibraryPreferences.ChapterSwipeAction) -> Unit,
// Chapter selection
onChapterSelected: (ChapterList.Item, Boolean, Boolean) -> Unit,
onChapterSelected: (ChapterList.Item, Boolean, Boolean, Boolean) -> Unit,
onAllChapterSelected: (Boolean) -> Unit,
onInvertSelection: () -> Unit,
@ -400,7 +404,7 @@ private fun MangaScreenSmallImpl(
onChapterSwipe: (ChapterList.Item, LibraryPreferences.ChapterSwipeAction) -> Unit,
// Chapter selection
onChapterSelected: (ChapterList.Item, Boolean, Boolean) -> Unit,
onChapterSelected: (ChapterList.Item, Boolean, Boolean, Boolean) -> Unit,
onAllChapterSelected: (Boolean) -> Unit,
onInvertSelection: () -> Unit,
@ -864,7 +868,7 @@ private fun MangaScreenLargeImpl(
onChapterSwipe: (ChapterList.Item, LibraryPreferences.ChapterSwipeAction) -> Unit,
// Chapter selection
onChapterSelected: (ChapterList.Item, Boolean, Boolean) -> Unit,
onChapterSelected: (ChapterList.Item, Boolean, Boolean, Boolean) -> Unit,
onAllChapterSelected: (Boolean) -> Unit,
onInvertSelection: () -> Unit,
@ -1303,7 +1307,7 @@ private fun LazyListScope.sharedChapterItems(
// SY <--
onChapterClicked: (Chapter) -> Unit,
onDownloadChapter: ((List<ChapterList.Item>, ChapterDownloadAction) -> Unit)?,
onChapterSelected: (ChapterList.Item, Boolean, Boolean) -> Unit,
onChapterSelected: (ChapterList.Item, Boolean, Boolean, Boolean) -> Unit,
onChapterSwipe: (ChapterList.Item, LibraryPreferences.ChapterSwipeAction) -> Unit,
) {
items(
@ -1372,14 +1376,14 @@ private fun LazyListScope.sharedChapterItems(
chapterSwipeStartAction = chapterSwipeStartAction,
chapterSwipeEndAction = chapterSwipeEndAction,
onLongClick = {
onChapterSelected(item, !item.selected, true, true)
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onChapterSelected(item, !item.selected, true)
},
onClick = {
onChapterItemClick(
chapterItem = item,
isAnyChapterSelected = isAnyChapterSelected,
onToggleSelection = { onChapterSelected(item, !item.selected, false) },
onToggleSelection = { onChapterSelected(item, !item.selected, true, false) },
onChapterClicked = onChapterClicked,
)
},
@ -1434,9 +1438,15 @@ fun metadataDescription(source: Source): MetadataDescriptionComposable? {
is EightMuses -> { state, openMetadataViewer, _ ->
EightMusesDescription(state, openMetadataViewer)
}
is HBrowse -> { state, openMetadataViewer, _ ->
HBrowseDescription(state, openMetadataViewer)
}
is Pururin -> { state, openMetadataViewer, _ ->
PururinDescription(state, openMetadataViewer)
}
is Tsumino -> { state, openMetadataViewer, _ ->
TsuminoDescription(state, openMetadataViewer)
}
is Lanraragi -> { state, openMetadataViewer, _ ->
LanraragiDescription(state, openMetadataViewer)
}

View file

@ -6,7 +6,6 @@ enum class DownloadAction {
NEXT_10_CHAPTERS,
NEXT_25_CHAPTERS,
UNREAD_CHAPTERS,
BOOKMARKED_CHAPTERS,
}
enum class EditCoverAction {

View file

@ -43,8 +43,8 @@ fun BaseMangaListItem(
onClick = onClickItem,
// KMK -->
onLongClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onLongClick()
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
},
// KMK <--
)

View file

@ -738,7 +738,7 @@ object SettingsAdvancedScreen : SearchableSettings {
subtitle = stringResource(SYMR.strings.data_saver_image_quality_summary),
enabled = dataSaver != DataSaver.NONE,
),
run {
kotlin.run {
val dataSaverImageFormatJpeg by sourcePreferences.dataSaverImageFormatJpeg()
.collectAsState()
Preference.PreferenceItem.SwitchPreference(
@ -808,7 +808,7 @@ object SettingsAdvancedScreen : SearchableSettings {
title = stringResource(SYMR.strings.log_level),
subtitle = stringResource(SYMR.strings.log_level_summary),
),
run {
kotlin.run {
var enableEncryptDatabase by rememberSaveable { mutableStateOf(false) }
if (enableEncryptDatabase) {

View file

@ -14,11 +14,11 @@ import eu.kanade.core.preference.asState
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.domain.ui.UiPreferences
import eu.kanade.presentation.more.settings.Preference
import eu.kanade.presentation.more.settings.screen.browse.ExtensionStoresScreen
import eu.kanade.presentation.more.settings.screen.browse.ExtensionReposScreen
import eu.kanade.tachiyomi.ui.category.sources.SourceCategoryScreen
import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.authenticate
import kotlinx.collections.immutable.persistentListOf
import mihon.domain.extension.interactor.GetExtensionStoreCountAsFlow
import mihon.domain.extensionrepo.interactor.GetExtensionRepoCount
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.i18n.MR
import tachiyomi.i18n.kmk.KMR
@ -43,9 +43,9 @@ object SettingsBrowseScreen : SearchableSettings {
val navigator = LocalNavigator.currentOrThrow
val sourcePreferences = remember { Injekt.get<SourcePreferences>() }
val getExtensionStoreCountAsFlow = remember { Injekt.get<GetExtensionStoreCountAsFlow>() }
val getExtensionRepoCount = remember { Injekt.get<GetExtensionRepoCount>() }
val reposCount by getExtensionStoreCountAsFlow().collectAsState(0)
val reposCount by getExtensionRepoCount.subscribe().collectAsState(0)
// SY -->
val scope = rememberCoroutineScope()
@ -85,7 +85,7 @@ object SettingsBrowseScreen : SearchableSettings {
enabled = sourcePreferences.relatedMangas().get(),
),
// KMK <--
run {
kotlin.run {
val count by sourcePreferences.sourcesTabCategories().collectAsState()
Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.action_edit_categories),
@ -142,10 +142,10 @@ object SettingsBrowseScreen : SearchableSettings {
title = stringResource(MR.strings.pref_hide_in_library_items),
),
Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.extensionStores),
subtitle = pluralStringResource(MR.plurals.num_repos, reposCount.toInt(), reposCount),
title = stringResource(MR.strings.label_extension_repos),
subtitle = pluralStringResource(MR.plurals.num_repos, reposCount, reposCount),
onClick = {
navigator.push(ExtensionStoresScreen())
navigator.push(ExtensionReposScreen())
},
),
),

View file

@ -48,7 +48,6 @@ import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import eu.kanade.domain.sync.SyncPreferences
import eu.kanade.presentation.more.settings.Preference
import eu.kanade.presentation.more.settings.screen.SettingsSecurityScreen.PasswordDialog
import eu.kanade.presentation.more.settings.screen.data.CreateBackupScreen
import eu.kanade.presentation.more.settings.screen.data.RestoreBackupScreen
import eu.kanade.presentation.more.settings.screen.data.StorageInfo
@ -563,7 +562,7 @@ object SettingsDataScreen : SearchableSettings {
SyncManager.SyncService.SYNCYOMI.value to stringResource(SYMR.strings.syncyomi),
SyncManager.SyncService.GOOGLE_DRIVE.value to stringResource(SYMR.strings.google_drive),
// KMK -->
SyncManager.SyncService.WEB_DAV.value to stringResource(KMR.strings.web_dav),
SyncManager.SyncService.WebDAV.value to stringResource(KMR.strings.web_dav),
// KMK <--
),
title = stringResource(SYMR.strings.pref_sync_service),
@ -607,7 +606,7 @@ object SettingsDataScreen : SearchableSettings {
SyncManager.SyncService.SYNCYOMI -> getSelfHostPreferences(syncPreferences)
SyncManager.SyncService.GOOGLE_DRIVE -> getGoogleDrivePreferences()
// KMK -->
SyncManager.SyncService.WEB_DAV -> getWebDavPreferences(syncPreferences)
SyncManager.SyncService.WebDAV -> getWebDavPreferences(syncPreferences)
// KMK <--
}
@ -760,9 +759,7 @@ object SettingsDataScreen : SearchableSettings {
title = stringResource(SYMR.strings.pref_sync_api_key),
subtitle = stringResource(SYMR.strings.pref_sync_api_key_summ),
onConfirm = {
scope.launch {
syncPreferences.clientAPIKey().set(it)
}
syncPreferences.clientAPIKey().set(it)
true
},
icon = null,
@ -811,28 +808,17 @@ object SettingsDataScreen : SearchableSettings {
true
},
),
run {
var dialogOpen by remember { mutableStateOf(false) }
if (dialogOpen) {
PasswordDialog(
onDismissRequest = { dialogOpen = false },
onReturnPassword = { password ->
dialogOpen = false
scope.launch {
syncPreferences.webDavPassword().set(password.replace("\n", ""))
}
},
title = KMR.strings.pref_webdav_password,
)
}
Preference.PreferenceItem.TextPreference(
title = stringResource(KMR.strings.pref_webdav_password),
subtitle = stringResource(KMR.strings.pref_webdav_password_summ),
onClick = {
dialogOpen = true
},
)
},
Preference.PreferenceItem.EditTextPreference(
preference = syncPreferences.webDavPassword(),
title = stringResource(KMR.strings.pref_webdav_password),
subtitle = stringResource(KMR.strings.pref_webdav_password_summ),
onValueChanged = { newValue ->
scope.launch {
syncPreferences.webDavPassword().set(newValue)
}
true
},
),
Preference.PreferenceItem.EditTextPreference(
preference = syncPreferences.webDavFolder(),
title = stringResource(KMR.strings.pref_webdav_folder),
@ -846,7 +832,7 @@ object SettingsDataScreen : SearchableSettings {
),
)
}
// KMK <--
// MK <--
@Composable
private fun getSyncNowPref(): Preference.PreferenceGroup {

View file

@ -18,7 +18,6 @@ import kotlinx.collections.immutable.toImmutableMap
import tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.category.model.Category
import tachiyomi.domain.download.service.DownloadPreferences
import tachiyomi.domain.download.service.LibraryUpdateDownloadMode
import tachiyomi.i18n.MR
import tachiyomi.i18n.kmk.KMR
import tachiyomi.presentation.core.i18n.pluralStringResource
@ -78,7 +77,6 @@ object SettingsDownloadScreen : SearchableSettings {
allCategories = allCategories,
),
getDownloadAheadGroup(downloadPreferences = downloadPreferences),
getLibraryUpdateDownloadGroup(downloadPreferences = downloadPreferences),
// KMK -->
getDownloadCacheRenewInterval(downloadPreferences = downloadPreferences),
// KMK <--
@ -218,39 +216,6 @@ object SettingsDownloadScreen : SearchableSettings {
)
}
@Composable
private fun getLibraryUpdateDownloadGroup(
downloadPreferences: DownloadPreferences,
): Preference.PreferenceGroup {
val modePref = downloadPreferences.libraryUpdateDownloadMode()
val amountPref = downloadPreferences.libraryUpdateDownloadAmount()
val mode by modePref.collectAsState()
return Preference.PreferenceGroup(
title = stringResource(KMR.strings.library_update_download),
preferenceItems = persistentListOf(
Preference.PreferenceItem.ListPreference(
preference = modePref,
entries = persistentMapOf(
LibraryUpdateDownloadMode.DISABLED to stringResource(MR.strings.disabled),
LibraryUpdateDownloadMode.NEXT_X to stringResource(KMR.strings.library_update_download_mode_next_x),
LibraryUpdateDownloadMode.ALL to stringResource(KMR.strings.library_update_download_mode_all),
),
title = stringResource(KMR.strings.library_update_download_mode),
),
Preference.PreferenceItem.ListPreference(
preference = amountPref,
entries = listOf(2, 3, 5, 10)
.associateWith { pluralStringResource(MR.plurals.next_unread_chapters, count = it, it) }
.toImmutableMap(),
title = stringResource(KMR.strings.library_update_download_amount),
enabled = mode == LibraryUpdateDownloadMode.NEXT_X,
),
Preference.PreferenceItem.InfoPreference(stringResource(KMR.strings.library_update_download_info)),
),
)
}
// KMK -->
@Composable
private fun getDownloadCacheRenewInterval(

View file

@ -293,14 +293,9 @@ object SettingsLibraryScreen : SearchableSettings {
title = stringResource(KMR.strings.pref_show_empty_categories_search),
),
Preference.PreferenceItem.SwitchPreference(
preference = libraryPreferences.fetchMetadataOnAdd(),
title = stringResource(KMR.strings.pref_fetch_manga_metadata_on_add),
subtitle = stringResource(KMR.strings.pref_fetch_manga_metadata_on_add_description),
),
Preference.PreferenceItem.SwitchPreference(
preference = libraryPreferences.fetchChaptersOnAdd(),
title = stringResource(KMR.strings.pref_fetch_manga_chapters_on_add),
subtitle = stringResource(KMR.strings.pref_fetch_manga_chapters_on_add_description),
preference = libraryPreferences.syncOnAdd(),
title = stringResource(KMR.strings.pref_sync_manga_on_add),
subtitle = stringResource(KMR.strings.pref_sync_manga_on_add_description),
),
// KMK <--
),

View file

@ -17,7 +17,6 @@ import eu.kanade.tachiyomi.util.system.hasDisplayCutout
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableMap
import tachiyomi.domain.reading.service.ReadingEtaPreferences
import tachiyomi.i18n.MR
import tachiyomi.i18n.kmk.KMR
import tachiyomi.i18n.sy.SYMR
@ -102,7 +101,6 @@ object SettingsReaderScreen : SearchableSettings {
),
SY <-- */
getDisplayGroup(readerPreferences = readerPref),
getReadingEtaTuningGroup(),
getEInkGroup(readerPreferences = readerPref),
getReadingGroup(readerPreferences = readerPref),
getPagedGroup(readerPreferences = readerPref),
@ -123,7 +121,6 @@ object SettingsReaderScreen : SearchableSettings {
private fun getDisplayGroup(readerPreferences: ReaderPreferences): Preference.PreferenceGroup {
val fullscreenPref = readerPreferences.fullscreen()
val fullscreen by fullscreenPref.collectAsState()
val showSeriesEta by readerPreferences.showSeriesEta().collectAsState()
return Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_category_display),
preferenceItems = persistentListOf(
@ -161,70 +158,6 @@ object SettingsReaderScreen : SearchableSettings {
preference = readerPreferences.showPageNumber(),
title = stringResource(MR.strings.pref_show_page_number),
),
Preference.PreferenceItem.SwitchPreference(
preference = readerPreferences.showSeriesProgress(),
title = stringResource(SYMR.strings.pref_show_series_progress),
),
Preference.PreferenceItem.SwitchPreference(
preference = readerPreferences.showSeriesEta(),
title = stringResource(SYMR.strings.pref_show_series_eta),
),
Preference.PreferenceItem.SwitchPreference(
preference = readerPreferences.persistentSeriesEta(),
title = stringResource(SYMR.strings.pref_persist_series_eta),
subtitle = stringResource(SYMR.strings.pref_persist_series_eta_summary),
enabled = showSeriesEta,
),
),
)
}
@Composable
private fun getReadingEtaTuningGroup(): Preference.PreferenceGroup {
val readingEtaPrefs = remember { Injekt.get<ReadingEtaPreferences>() }
val etaMaxStatsPref = readingEtaPrefs.maxStatsDurationMinutes()
val etaMaxTrustedPref = readingEtaPrefs.maxTrustedSessionMinutes()
val etaSamplePref = readingEtaPrefs.sampleChapterLimit()
val etaMaxPagesPref = readingEtaPrefs.maxPagesReadForPageRate()
val etaMaxStats by etaMaxStatsPref.collectAsState()
val etaMaxTrusted by etaMaxTrustedPref.collectAsState()
val etaSample by etaSamplePref.collectAsState()
val etaMaxPages by etaMaxPagesPref.collectAsState()
return Preference.PreferenceGroup(
title = stringResource(KMR.strings.pref_reading_eta_group_title),
preferenceItems = persistentListOf(
Preference.PreferenceItem.SliderPreference(
value = etaMaxStats,
valueRange = 5..180,
title = stringResource(KMR.strings.pref_reading_eta_max_stats_min),
subtitle = stringResource(KMR.strings.pref_reading_eta_max_stats_min_summary),
valueString = stringResource(KMR.strings.pref_reading_eta_max_stats_min_value, etaMaxStats),
onValueChanged = { etaMaxStatsPref.set(it) },
),
Preference.PreferenceItem.SliderPreference(
value = etaMaxTrusted,
valueRange = 60..1440,
title = stringResource(KMR.strings.pref_reading_eta_max_trusted_min),
subtitle = stringResource(KMR.strings.pref_reading_eta_max_trusted_min_summary),
valueString = stringResource(KMR.strings.pref_reading_eta_max_trusted_min_value, etaMaxTrusted),
onValueChanged = { etaMaxTrustedPref.set(it) },
),
Preference.PreferenceItem.SliderPreference(
value = etaSample,
valueRange = 5..100,
title = stringResource(KMR.strings.pref_reading_eta_sample_chapters),
subtitle = stringResource(KMR.strings.pref_reading_eta_sample_chapters_summary),
valueString = stringResource(KMR.strings.pref_reading_eta_sample_chapters_value, etaSample),
onValueChanged = { etaSamplePref.set(it) },
),
Preference.PreferenceItem.SliderPreference(
value = (etaMaxPages / 100).coerceIn(10, 500),
valueRange = 10..500,
title = stringResource(KMR.strings.pref_reading_eta_max_pages_sample),
subtitle = stringResource(KMR.strings.pref_reading_eta_max_pages_sample_summary),
valueString = stringResource(KMR.strings.pref_reading_eta_max_pages_sample_value, etaMaxPages),
onValueChanged = { etaMaxPagesPref.set(it * 100) },
),
),
)
}

View file

@ -155,7 +155,7 @@ object SettingsSecurityScreen : SearchableSettings {
enabled = passwordProtectDownloads,
),
run {
kotlin.run {
var dialogOpen by remember { mutableStateOf(false) }
if (dialogOpen) {
PasswordDialog(
@ -183,7 +183,7 @@ object SettingsSecurityScreen : SearchableSettings {
securityPreferences.cbzPassword().set("")
},
),
run {
kotlin.run {
val navigator = LocalNavigator.currentOrThrow
val count by securityPreferences.authenticatorTimeRanges().collectAsState()
Preference.PreferenceItem.TextPreference(
@ -199,7 +199,7 @@ object SettingsSecurityScreen : SearchableSettings {
},
)
},
run {
kotlin.run {
val selection by securityPreferences.authenticatorDays().collectAsState()
var dialogOpen by remember { mutableStateOf(false) }
if (dialogOpen) {
@ -332,16 +332,13 @@ object SettingsSecurityScreen : SearchableSettings {
fun PasswordDialog(
onDismissRequest: () -> Unit,
onReturnPassword: (String) -> Unit,
// KMK -->
title: StringResource = SYMR.strings.cbz_archive_password,
// KMK <--
) {
var password by rememberSaveable { mutableStateOf("") }
var passwordVisibility by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismissRequest,
title = { Text(text = stringResource(title)) },
title = { Text(text = stringResource(SYMR.strings.cbz_archive_password)) },
text = {
TextField(
value = password,

View file

@ -8,18 +8,20 @@ import androidx.compose.ui.platform.LocalContext
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionStoreConfirmDialog
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionStoreCreateDialog
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionStoreDeleteDialog
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionStoresScreen
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionRepoConfirmDialog
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionRepoConflictDialog
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionRepoCreateDialog
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionRepoDeleteDialog
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionReposScreen
import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.util.system.copyToClipboard
import eu.kanade.tachiyomi.util.system.openInBrowser
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.flow.collectLatest
import tachiyomi.i18n.kmk.KMR
import tachiyomi.presentation.core.screens.LoadingScreen
class ExtensionStoresScreen(
class ExtensionReposScreen(
private val url: String? = null,
) : Screen() {
@ -28,72 +30,80 @@ class ExtensionStoresScreen(
val context = LocalContext.current
val navigator = LocalNavigator.currentOrThrow
val screenModel = rememberScreenModel { ExtensionStoresScreenModel() }
val screenModel = rememberScreenModel { ExtensionReposScreenModel() }
val state by screenModel.state.collectAsState()
LaunchedEffect(url) {
url?.let { screenModel.addFromDeeplink(url) }
url?.let { screenModel.showDialog(RepoDialog.Confirm(it)) }
}
if (state is ExtensionStoreScreenState.Loading) {
if (state is RepoScreenState.Loading) {
LoadingScreen()
return
}
val successState = state as ExtensionStoreScreenState.Success
val successState = state as RepoScreenState.Success
ExtensionStoresScreen(
ExtensionReposScreen(
state = successState,
onClickCreate = { screenModel.showDialog(ExtensionStoreDialog.Create()) },
onCopy = { context.copyToClipboard(it.indexUrl, it.indexUrl) },
onOpenWebsite = { it.contact.website.let(context::openInBrowser) },
onOpenDiscord = { it.contact.discord?.let(context::openInBrowser) },
onClickCreate = { screenModel.showDialog(RepoDialog.Create) },
onOpenWebsite = { context.openInBrowser(it.website) },
onClickDelete = { screenModel.showDialog(RepoDialog.Delete(it)) },
// KMK -->
onClickEnable = {
screenModel.enableStore(it.indexUrl)
screenModel.enableRepo(it)
screenModel.refreshExtensionList()
context.toast(KMR.strings.extensions_page_need_refresh)
},
onClickDisable = {
screenModel.disableStore(it.indexUrl)
screenModel.disableRepo(it)
screenModel.refreshExtensionList()
context.toast(KMR.strings.extensions_page_need_refresh)
},
// KMK <--
onClickDelete = { screenModel.showDialog(ExtensionStoreDialog.Delete(it)) },
onClickRefresh = { screenModel.refreshRepos() },
navigateUp = navigator::pop,
)
when (val dialog = successState.dialog) {
null -> {}
is ExtensionStoreDialog.Create -> {
ExtensionStoreCreateDialog(
is RepoDialog.Create -> {
ExtensionRepoCreateDialog(
onDismissRequest = screenModel::dismissDialog,
onCreate = { screenModel.createRepo(it) },
storeIndexUrls = successState.stores.map { it.indexUrl }.toSet(),
processing = dialog.processing,
errorMessage = dialog.errorMessage,
repoUrls = successState.repos.map { it.baseUrl }.toImmutableSet(),
)
}
is ExtensionStoreDialog.Delete -> {
ExtensionStoreDeleteDialog(
is RepoDialog.Delete -> {
ExtensionRepoDeleteDialog(
onDismissRequest = screenModel::dismissDialog,
onDelete = { screenModel.deleteRepo(dialog.store.indexUrl) },
storeName = dialog.store.name,
storeIndexUrl = dialog.store.indexUrl,
onDelete = { screenModel.deleteRepo(dialog.repo) },
repo = dialog.repo,
)
}
is ExtensionStoreDialog.Confirm -> {
ExtensionStoreConfirmDialog(
is RepoDialog.Conflict -> {
ExtensionRepoConflictDialog(
onDismissRequest = screenModel::dismissDialog,
onMigrate = { screenModel.replaceRepo(dialog.newRepo) },
oldRepo = dialog.oldRepo,
newRepo = dialog.newRepo,
)
}
is RepoDialog.Confirm -> {
ExtensionRepoConfirmDialog(
onDismissRequest = screenModel::dismissDialog,
onCreate = { screenModel.createRepo(dialog.url) },
storeIndexUrl = dialog.url,
storeAlreadyExists = dialog.alreadyExists,
processing = dialog.processing,
errorMessage = dialog.errorMessage,
repo = dialog.url,
)
}
}
LaunchedEffect(Unit) {
screenModel.events.collectLatest { event ->
if (event is RepoEvent.LocalizedMessage) {
context.toast(event.stringRes)
}
}
}
}
}

View file

@ -0,0 +1,205 @@
package eu.kanade.presentation.more.settings.screen.browse
import androidx.compose.runtime.Immutable
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import dev.icerock.moko.resources.StringResource
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.tachiyomi.extension.ExtensionManager
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import mihon.domain.extensionrepo.interactor.CreateExtensionRepo
import mihon.domain.extensionrepo.interactor.DeleteExtensionRepo
import mihon.domain.extensionrepo.interactor.GetExtensionRepo
import mihon.domain.extensionrepo.interactor.ReplaceExtensionRepo
import mihon.domain.extensionrepo.interactor.UpdateExtensionRepo
import mihon.domain.extensionrepo.model.ExtensionRepo
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.i18n.MR
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class ExtensionReposScreenModel(
private val getExtensionRepo: GetExtensionRepo = Injekt.get(),
private val createExtensionRepo: CreateExtensionRepo = Injekt.get(),
private val deleteExtensionRepo: DeleteExtensionRepo = Injekt.get(),
private val replaceExtensionRepo: ReplaceExtensionRepo = Injekt.get(),
private val updateExtensionRepo: UpdateExtensionRepo = Injekt.get(),
private val extensionManager: ExtensionManager = Injekt.get(),
// KMK -->
private val sourcePreferences: SourcePreferences = Injekt.get(),
// KMK <--
) : StateScreenModel<RepoScreenState>(RepoScreenState.Loading) {
private val _events: Channel<RepoEvent> = Channel(Int.MAX_VALUE)
val events = _events.receiveAsFlow()
init {
screenModelScope.launchIO {
getExtensionRepo.subscribeAll()
.collectLatest { repos ->
mutableState.update {
RepoScreenState.Success(
repos = repos.toImmutableSet(),
// KMK -->
disabledRepos = sourcePreferences.disabledRepos().get(),
// KMK <--
)
}
}
}
// KMK -->
sourcePreferences.disabledRepos().changes()
.onEach { disabledRepos ->
mutableState.update {
when (it) {
is RepoScreenState.Success -> it.copy(disabledRepos = disabledRepos)
else -> it
}
}
}
.launchIn(screenModelScope)
// KMK <--
}
/**
* Creates and adds a new repo to the database.
*
* @param baseUrl The baseUrl of the repo to create.
*/
fun createRepo(baseUrl: String) {
screenModelScope.launchIO {
when (val result = createExtensionRepo.await(baseUrl)) {
CreateExtensionRepo.Result.Success -> extensionManager.findAvailableExtensions()
CreateExtensionRepo.Result.InvalidUrl -> _events.send(RepoEvent.InvalidUrl)
CreateExtensionRepo.Result.RepoAlreadyExists -> _events.send(RepoEvent.RepoAlreadyExists)
is CreateExtensionRepo.Result.DuplicateFingerprint -> {
showDialog(RepoDialog.Conflict(result.oldRepo, result.newRepo))
}
else -> {}
}
}
}
/**
* Inserts a repo to the database, replace a matching repo with the same signing key fingerprint if found.
*
* @param newRepo The repo to insert
*/
fun replaceRepo(newRepo: ExtensionRepo) {
screenModelScope.launchIO {
replaceExtensionRepo.await(newRepo)
}
}
/**
* Refreshes information for each repository.
*/
fun refreshRepos() {
val status = state.value
if (status is RepoScreenState.Success) {
screenModelScope.launchIO {
updateExtensionRepo.awaitAll()
}
}
}
/**
* Deletes the given repo from the database
*/
fun deleteRepo(baseUrl: String) {
// KMK -->
// Remove repo from disabled list
enableRepo(baseUrl)
// KMK <--
screenModelScope.launchIO {
deleteExtensionRepo.await(baseUrl)
extensionManager.findAvailableExtensions()
}
}
// KMK -->
fun enableRepo(baseUrl: String) {
val disabledRepos = sourcePreferences.disabledRepos().get()
if (baseUrl in disabledRepos) {
sourcePreferences.disabledRepos().set(
disabledRepos.filterNot { it == baseUrl }.toSet(),
)
}
}
fun disableRepo(baseUrl: String) {
val disabledRepos = sourcePreferences.disabledRepos().get()
if (baseUrl !in disabledRepos) {
sourcePreferences.disabledRepos().set(
disabledRepos + baseUrl,
)
}
}
fun refreshExtensionList() {
screenModelScope.launchIO {
extensionManager.findAvailableExtensions()
}
}
// KMK <--
fun showDialog(dialog: RepoDialog) {
mutableState.update {
when (it) {
RepoScreenState.Loading -> it
is RepoScreenState.Success -> it.copy(dialog = dialog)
}
}
}
fun dismissDialog() {
mutableState.update {
when (it) {
RepoScreenState.Loading -> it
is RepoScreenState.Success -> it.copy(dialog = null)
}
}
}
}
sealed class RepoEvent {
sealed class LocalizedMessage(val stringRes: StringResource) : RepoEvent()
data object InvalidUrl : LocalizedMessage(MR.strings.invalid_repo_name)
data object RepoAlreadyExists : LocalizedMessage(MR.strings.error_repo_exists)
}
sealed class RepoDialog {
data object Create : RepoDialog()
data class Delete(val repo: String) : RepoDialog()
data class Conflict(val oldRepo: ExtensionRepo, val newRepo: ExtensionRepo) : RepoDialog()
data class Confirm(val url: String) : RepoDialog()
}
sealed class RepoScreenState {
@Immutable
data object Loading : RepoScreenState()
@Immutable
data class Success(
val repos: ImmutableSet<ExtensionRepo>,
val oldRepos: ImmutableSet<String>? = null,
val dialog: RepoDialog? = null,
// KMK -->
val disabledRepos: Set<String> = emptySet(),
// KMK <--
) : RepoScreenState() {
val isEmpty: Boolean
get() = repos.isEmpty()
}
}

View file

@ -1,223 +0,0 @@
package eu.kanade.presentation.more.settings.screen.browse
import androidx.compose.runtime.Immutable
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.tachiyomi.extension.ExtensionManager
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import mihon.domain.extension.interactor.AddExtensionStore
import mihon.domain.extension.interactor.GetExtensionStores
import mihon.domain.extension.interactor.RemoveExtensionStore
import mihon.domain.extension.interactor.UpdateExtensionStores
import mihon.domain.extension.model.ExtensionStore
import tachiyomi.core.common.util.lang.launchIO
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class ExtensionStoresScreenModel(
private val getExtensionStores: GetExtensionStores = Injekt.get(),
private val addExtensionStore: AddExtensionStore = Injekt.get(),
private val removeExtensionStore: RemoveExtensionStore = Injekt.get(),
private val updateExtensionStores: UpdateExtensionStores = Injekt.get(),
private val extensionManager: ExtensionManager = Injekt.get(),
// KMK -->
private val sourcePreferences: SourcePreferences = Injekt.get(),
// KMK <--
) : StateScreenModel<ExtensionStoreScreenState>(ExtensionStoreScreenState.Loading) {
private inline fun updateSuccessState(
func: (ExtensionStoreScreenState.Success) -> ExtensionStoreScreenState.Success,
) {
mutableState.update {
when (it) {
ExtensionStoreScreenState.Loading -> it
is ExtensionStoreScreenState.Success -> func(it)
}
}
}
init {
screenModelScope.launchIO {
getExtensionStores.subscribe()
.collectLatest { stores ->
mutableState.update {
when (it) {
ExtensionStoreScreenState.Loading -> ExtensionStoreScreenState.Success(
stores = stores,
// KMK -->
disabledRepos = sourcePreferences.disabledRepos().get(),
// KMK <--
)
is ExtensionStoreScreenState.Success -> it.copy(stores = stores)
}
}
}
}
// KMK -->
sourcePreferences.disabledRepos().changes()
.onEach { disabledRepos ->
mutableState.update {
when (it) {
is ExtensionStoreScreenState.Success -> it.copy(disabledRepos = disabledRepos)
else -> it
}
}
}
.launchIn(screenModelScope)
// KMK <--
}
/**
* Creates and adds a new repo to the database.
*
* @param indexUrl The baseUrl of the repo to create.
*/
fun createRepo(indexUrl: String) {
// KMK -->
screenModelScope.launchIO {
// KMK <--
updateSuccessState {
it.copy(
dialog = when (it.dialog) {
is ExtensionStoreDialog.Create -> it.dialog.copy(processing = true)
is ExtensionStoreDialog.Confirm -> it.dialog.copy(processing = true)
else -> it.dialog
},
)
}
addExtensionStore(indexUrl)
.onSuccess {
extensionManager.findAvailableExtensions()
dismissDialog()
}
.onFailure { throwable ->
updateSuccessState {
it.copy(
dialog = when (it.dialog) {
is ExtensionStoreDialog.Create -> it.dialog.copy(
processing = false,
errorMessage = throwable.message ?: "unknown error",
)
is ExtensionStoreDialog.Confirm -> it.dialog.copy(
processing = false,
errorMessage = throwable.message ?: "unknown error",
)
else -> it.dialog
},
)
}
}
}
}
/**
* Refreshes information for each repository.
*/
fun refreshRepos() {
val status = state.value
if (status is ExtensionStoreScreenState.Success) {
screenModelScope.launchIO {
updateExtensionStores()
}
}
}
/**
* Deletes the given repo from the database
*/
fun deleteRepo(indexUrl: String) {
// KMK -->
// Remove repo from disabled list
enableStore(indexUrl)
// KMK <--
screenModelScope.launchIO {
removeExtensionStore(indexUrl)
extensionManager.findAvailableExtensions()
}
}
// KMK -->
fun enableStore(indexUrl: String) {
val disabledRepos = sourcePreferences.disabledRepos().get()
if (indexUrl in disabledRepos) {
sourcePreferences.disabledRepos().set(
disabledRepos.filterNot { it == indexUrl }.toSet(),
)
}
}
fun disableStore(indexUrl: String) {
val disabledRepos = sourcePreferences.disabledRepos().get()
if (indexUrl !in disabledRepos) {
sourcePreferences.disabledRepos().set(
disabledRepos + indexUrl,
)
}
}
fun refreshExtensionList() {
screenModelScope.launchIO {
extensionManager.findAvailableExtensions()
}
}
// KMK <--
fun addFromDeeplink(storeIndexUrl: String) {
updateSuccessState { state ->
state.copy(
dialog = ExtensionStoreDialog.Confirm(
url = storeIndexUrl,
alreadyExists = state.stores.any { it.indexUrl == storeIndexUrl },
),
)
}
}
fun showDialog(dialog: ExtensionStoreDialog) {
updateSuccessState { state ->
state.copy(dialog = dialog)
}
}
fun dismissDialog() {
updateSuccessState {
it.copy(dialog = null)
}
}
}
sealed class ExtensionStoreDialog {
data class Create(val processing: Boolean = false, val errorMessage: String? = null) : ExtensionStoreDialog()
data class Delete(val store: ExtensionStore) : ExtensionStoreDialog()
data class Confirm(
val url: String,
val alreadyExists: Boolean = false,
val processing: Boolean = false,
val errorMessage: String? = null,
) : ExtensionStoreDialog()
}
sealed class ExtensionStoreScreenState {
@Immutable
data object Loading : ExtensionStoreScreenState()
@Immutable
data class Success(
val stores: List<ExtensionStore>,
val dialog: ExtensionStoreDialog? = null,
// KMK -->
val disabledRepos: Set<String> = emptySet(),
// KMK <--
) : ExtensionStoreScreenState() {
val isEmpty: Boolean
get() = stores.isEmpty()
}
}

View file

@ -11,9 +11,9 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.OpenInNew
import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Public
import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.material3.ElevatedCard
@ -26,33 +26,33 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.imageResource
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import eu.kanade.tachiyomi.R
import kotlinx.collections.immutable.persistentListOf
import mihon.domain.extension.model.ExtensionStore
import mihon.domain.extension.model.KOMIKKU_SIGNATURE
import mihon.domain.extension.model.REPO_SIGNATURE
import eu.kanade.tachiyomi.util.system.copyToClipboard
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
import mihon.domain.extensionrepo.interactor.CreateExtensionRepo.Companion.KOMIKKU_SIGNATURE
import mihon.domain.extensionrepo.interactor.CreateExtensionRepo.Companion.REPO_SIGNATURE
import mihon.domain.extensionrepo.model.ExtensionRepo
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.icons.CustomIcons
import tachiyomi.presentation.core.icons.Discord
@Composable
fun ExtensionStoresContent(
repos: List<ExtensionStore>,
fun ExtensionReposContent(
repos: ImmutableSet<ExtensionRepo>,
lazyListState: LazyListState,
paddingValues: PaddingValues,
onCopy: (ExtensionStore) -> Unit,
onOpenWebsite: (ExtensionStore) -> Unit,
onOpenDiscord: (ExtensionStore) -> Unit,
onClickDelete: (ExtensionStore) -> Unit,
onOpenWebsite: (ExtensionRepo) -> Unit,
onClickDelete: (String) -> Unit,
// KMK -->
onClickEnable: (ExtensionStore) -> Unit,
onClickDisable: (ExtensionStore) -> Unit,
onClickEnable: (String) -> Unit,
onClickDisable: (String) -> Unit,
disabledRepos: Set<String>,
// KMK <--
modifier: Modifier = Modifier,
@ -65,17 +65,15 @@ fun ExtensionStoresContent(
) {
repos.forEach {
item {
ExtensionStoresListItem(
ExtensionRepoListItem(
modifier = Modifier.animateItem(),
store = it,
repo = it,
onOpenWebsite = { onOpenWebsite(it) },
onOpenDiscord = { onOpenDiscord(it) },
onCopy = { onCopy(it) },
onDelete = { onClickDelete(it) },
onDelete = { onClickDelete(it.baseUrl) },
// KMK -->
onEnable = { onClickEnable(it) },
onDisable = { onClickDisable(it) },
isDisabled = it.indexUrl in disabledRepos,
onEnable = { onClickEnable(it.baseUrl) },
onDisable = { onClickDisable(it.baseUrl) },
isDisabled = it.baseUrl in disabledRepos,
// KMK <--
)
}
@ -84,11 +82,9 @@ fun ExtensionStoresContent(
}
@Composable
private fun ExtensionStoresListItem(
store: ExtensionStore,
private fun ExtensionRepoListItem(
repo: ExtensionRepo,
onOpenWebsite: () -> Unit,
onOpenDiscord: () -> Unit,
onCopy: () -> Unit,
onDelete: () -> Unit,
modifier: Modifier = Modifier,
// KMK -->
@ -97,6 +93,8 @@ private fun ExtensionStoresListItem(
onDisable: () -> Unit,
// KMK <--
) {
val context = LocalContext.current
ElevatedCard(
modifier = modifier,
) {
@ -105,9 +103,9 @@ private fun ExtensionStoresListItem(
modifier = Modifier
.padding(start = MaterialTheme.padding.medium),
) {
val resId = repoResId(store.signingKey)
val resId = repoResId(repo.signingKeyFingerprint)
Image(
painter = painterResource(id = resId),
bitmap = ImageBitmap.imageResource(id = resId),
contentDescription = null,
alpha = if (isDisabled) 0.4f else 1f,
modifier = Modifier
@ -128,7 +126,7 @@ private fun ExtensionStoresListItem(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = store.name,
text = repo.name,
// KMK: modifier = Modifier.padding(start = MaterialTheme.padding.medium),
style = MaterialTheme.typography.titleMedium,
// KMK -->
@ -144,21 +142,17 @@ private fun ExtensionStoresListItem(
) {
IconButton(onClick = onOpenWebsite) {
Icon(
imageVector = Icons.Outlined.Public,
imageVector = Icons.AutoMirrored.Outlined.OpenInNew,
contentDescription = stringResource(MR.strings.action_open_in_browser),
)
}
if (store.contact.discord != null) {
IconButton(onClick = onOpenDiscord) {
Icon(
imageVector = CustomIcons.Discord,
contentDescription = null,
)
}
}
IconButton(onClick = onCopy) {
IconButton(
onClick = {
val url = "${repo.baseUrl}/index.min.json"
context.copyToClipboard(url, url)
},
) {
Icon(
imageVector = Icons.Outlined.ContentCopy,
contentDescription = stringResource(MR.strings.action_copy_to_clipboard),
@ -196,18 +190,16 @@ fun repoResId(signKey: String) = when (signKey) {
@Preview
@Composable
fun ExtensionReposContentPreview() {
val repos = persistentListOf(
ExtensionStore("https://repo", "Komikku", "", KOMIKKU_SIGNATURE, ExtensionStore.Contact("", ""), false),
ExtensionStore("https://repo", "Repo", "", REPO_SIGNATURE, ExtensionStore.Contact("", ""), false),
ExtensionStore("https://repo", "Other", "", "key2", ExtensionStore.Contact("", ""), true),
val repos = persistentSetOf(
ExtensionRepo("https://repo", "Komikku", "", "", KOMIKKU_SIGNATURE),
ExtensionRepo("https://repo", "Repo", "", "", REPO_SIGNATURE),
ExtensionRepo("https://repo", "Other", "", "", "key2"),
)
ExtensionStoresContent(
ExtensionReposContent(
repos = repos,
lazyListState = LazyListState(),
paddingValues = PaddingValues(),
onCopy = {},
onOpenWebsite = {},
onOpenDiscord = {},
onClickDelete = {},
onClickEnable = {},
onClickDisable = {},

View file

@ -0,0 +1,188 @@
package eu.kanade.presentation.more.settings.screen.browse.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.text.input.KeyboardType
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.coroutines.delay
import mihon.domain.extensionrepo.model.ExtensionRepo
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
import kotlin.time.Duration.Companion.seconds
@Composable
fun ExtensionRepoCreateDialog(
onDismissRequest: () -> Unit,
onCreate: (String) -> Unit,
repoUrls: ImmutableSet<String>,
) {
var name by remember { mutableStateOf("") }
val focusRequester = remember { FocusRequester() }
val nameAlreadyExists = remember(name) { repoUrls.contains(name) }
AlertDialog(
onDismissRequest = onDismissRequest,
confirmButton = {
TextButton(
enabled = name.isNotEmpty() && !nameAlreadyExists,
onClick = {
onCreate(name)
onDismissRequest()
},
) {
Text(text = stringResource(MR.strings.action_add))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(MR.strings.action_cancel))
}
},
title = {
Text(text = stringResource(MR.strings.action_add_repo))
},
text = {
Column {
Text(text = stringResource(MR.strings.action_add_repo_message, stringResource(MR.strings.app_name)))
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
value = name,
onValueChange = { name = it },
label = {
Text(text = stringResource(MR.strings.label_add_repo_input))
},
supportingText = {
val msgRes = if (name.isNotEmpty() && nameAlreadyExists) {
MR.strings.error_repo_exists
} else {
MR.strings.information_required_plain
}
Text(text = stringResource(msgRes))
},
isError = name.isNotEmpty() && nameAlreadyExists,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
singleLine = true,
)
}
},
)
LaunchedEffect(focusRequester) {
// TODO: https://issuetracker.google.com/issues/204502668
delay(0.1.seconds)
focusRequester.requestFocus()
}
}
@Composable
fun ExtensionRepoDeleteDialog(
onDismissRequest: () -> Unit,
onDelete: () -> Unit,
repo: String,
) {
AlertDialog(
onDismissRequest = onDismissRequest,
confirmButton = {
TextButton(onClick = {
onDelete()
onDismissRequest()
}) {
Text(text = stringResource(MR.strings.action_ok))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(MR.strings.action_cancel))
}
},
title = {
Text(text = stringResource(MR.strings.action_delete_repo))
},
text = {
Text(text = stringResource(MR.strings.delete_repo_confirmation, repo))
},
)
}
@Composable
fun ExtensionRepoConflictDialog(
oldRepo: ExtensionRepo,
newRepo: ExtensionRepo,
onDismissRequest: () -> Unit,
onMigrate: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismissRequest,
confirmButton = {
TextButton(
onClick = {
onMigrate()
onDismissRequest()
},
) {
Text(text = stringResource(MR.strings.action_replace_repo))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(MR.strings.action_cancel))
}
},
title = {
Text(text = stringResource(MR.strings.action_replace_repo_title))
},
text = {
Text(text = stringResource(MR.strings.action_replace_repo_message, newRepo.name, oldRepo.name))
},
)
}
@Composable
fun ExtensionRepoConfirmDialog(
onDismissRequest: () -> Unit,
onCreate: () -> Unit,
repo: String,
) {
AlertDialog(
onDismissRequest = onDismissRequest,
title = {
Text(text = stringResource(MR.strings.action_add_repo))
},
text = {
Text(text = stringResource(MR.strings.add_repo_confirmation, repo))
},
confirmButton = {
TextButton(
onClick = {
onCreate()
onDismissRequest()
},
) {
Text(text = stringResource(MR.strings.action_add))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(MR.strings.action_cancel))
}
},
)
}

View file

@ -1,3 +1,5 @@
@file:JvmName("ExtensionReposScreenKt")
package eu.kanade.presentation.more.settings.screen.browse.components
import androidx.compose.foundation.layout.PaddingValues
@ -19,13 +21,13 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import eu.kanade.presentation.category.components.CategoryFloatingActionButton
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.more.settings.screen.browse.ExtensionStoreScreenState
import eu.kanade.presentation.more.settings.screen.browse.RepoScreenState
import eu.kanade.tachiyomi.util.system.openInBrowser
import kotlinx.collections.immutable.persistentListOf
import mihon.domain.extension.model.ExtensionStore
import mihon.domain.extension.model.KOMIKKU_SIGNATURE
import mihon.domain.extension.model.REPO_HELP
import mihon.domain.extension.model.REPO_SIGNATURE
import kotlinx.collections.immutable.persistentSetOf
import mihon.domain.extensionrepo.interactor.CreateExtensionRepo.Companion.KOMIKKU_SIGNATURE
import mihon.domain.extensionrepo.interactor.CreateExtensionRepo.Companion.REPO_HELP
import mihon.domain.extensionrepo.interactor.CreateExtensionRepo.Companion.REPO_SIGNATURE
import mihon.domain.extensionrepo.model.ExtensionRepo
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.components.material.padding
@ -35,16 +37,14 @@ import tachiyomi.presentation.core.screens.EmptyScreen
import tachiyomi.presentation.core.util.plus
@Composable
fun ExtensionStoresScreen(
state: ExtensionStoreScreenState.Success,
fun ExtensionReposScreen(
state: RepoScreenState.Success,
onClickCreate: () -> Unit,
onCopy: (ExtensionStore) -> Unit,
onOpenWebsite: (ExtensionStore) -> Unit,
onOpenDiscord: (ExtensionStore) -> Unit,
onClickDelete: (ExtensionStore) -> Unit,
onOpenWebsite: (ExtensionRepo) -> Unit,
onClickDelete: (String) -> Unit,
// KMK -->
onClickEnable: (ExtensionStore) -> Unit,
onClickDisable: (ExtensionStore) -> Unit,
onClickEnable: (String) -> Unit,
onClickDisable: (String) -> Unit,
// KMK <--
onClickRefresh: () -> Unit,
navigateUp: () -> Unit,
@ -54,7 +54,7 @@ fun ExtensionStoresScreen(
topBar = { scrollBehavior ->
AppBar(
navigateUp = navigateUp,
title = stringResource(MR.strings.extensionStores),
title = stringResource(MR.strings.label_extension_repos),
scrollBehavior = scrollBehavior,
actions = {
IconButton(onClick = onClickRefresh) {
@ -76,7 +76,7 @@ fun ExtensionStoresScreen(
if (state.isEmpty) {
val context = LocalContext.current
EmptyScreen(
MR.strings.extensionStoresScreen_emptyLabel,
MR.strings.information_empty_repos,
modifier = Modifier.padding(paddingValues),
// KMK -->
help = {
@ -94,14 +94,12 @@ fun ExtensionStoresScreen(
return@Scaffold
}
ExtensionStoresContent(
repos = state.stores,
ExtensionReposContent(
repos = state.repos,
lazyListState = lazyListState,
paddingValues = paddingValues + topSmallPaddingValues +
PaddingValues(horizontal = MaterialTheme.padding.medium),
onCopy = onCopy,
onOpenWebsite = onOpenWebsite,
onOpenDiscord = onOpenDiscord,
onClickDelete = onClickDelete,
// KMK -->
onClickEnable = onClickEnable,
@ -115,21 +113,19 @@ fun ExtensionStoresScreen(
// KMK -->
@Preview
@Composable
private fun ExtensionStoresScreenPreview() {
val state = ExtensionStoreScreenState.Success(
stores = persistentListOf(
ExtensionStore("https://repo", "Komikku", "", KOMIKKU_SIGNATURE, ExtensionStore.Contact("", ""), false),
ExtensionStore("https://repo", "Repo", "", REPO_SIGNATURE, ExtensionStore.Contact("", ""), false),
ExtensionStore("https://repo", "Other", "", "key2", ExtensionStore.Contact("", ""), true),
private fun ExtensionReposScreenPreview() {
val state = RepoScreenState.Success(
repos = persistentSetOf(
ExtensionRepo("https://repo", "Komikku", "", "", KOMIKKU_SIGNATURE),
ExtensionRepo("https://repo", "Repo", "", "", REPO_SIGNATURE),
ExtensionRepo("https://repo", "Other", "", "", "key2"),
),
disabledRepos = setOf("https://repo"),
)
ExtensionStoresScreen(
ExtensionReposScreen(
state = state,
onClickCreate = { },
onCopy = { },
onOpenWebsite = { },
onOpenDiscord = { },
onClickDelete = { },
onClickEnable = { },
onClickDisable = { },
@ -140,14 +136,12 @@ private fun ExtensionStoresScreenPreview() {
@Preview
@Composable
private fun ExtensionStoresScreenEmptyPreview() {
val state = ExtensionStoreScreenState.Success(stores = persistentListOf())
ExtensionStoresScreen(
private fun ExtensionReposScreenEmptyPreview() {
val state = RepoScreenState.Success(repos = persistentSetOf())
ExtensionReposScreen(
state = state,
onClickCreate = { },
onCopy = { },
onOpenWebsite = { },
onOpenDiscord = { },
onClickDelete = { },
onClickEnable = { },
onClickDisable = { },

View file

@ -1,192 +0,0 @@
package eu.kanade.presentation.more.settings.screen.browse.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
import kotlin.time.Duration.Companion.seconds
@Composable
fun ExtensionStoreCreateDialog(
onDismissRequest: () -> Unit,
onCreate: (String) -> Unit,
storeIndexUrls: Set<String>,
processing: Boolean,
errorMessage: String?,
) {
val state = rememberTextFieldState()
val storeAlreadyExists by remember(storeIndexUrls) {
derivedStateOf {
val indexUrl = state.text.toString()
storeIndexUrls.contains(indexUrl)
}
}
val focusRequester = remember { FocusRequester() }
AlertDialog(
onDismissRequest = onDismissRequest,
title = {
Text(text = stringResource(MR.strings.extensionStoresScreen_addStore_title))
},
text = {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
state = state,
label = {
Text(text = stringResource(MR.strings.extensionStoresScreen_addStoreInput_inputLabel))
},
supportingText = {
val msgRes = if (storeAlreadyExists) {
MR.strings.extensionStoresScreen_addStore_alreadyExists
} else {
MR.strings.information_required_plain
}
Text(text = errorMessage ?: stringResource(msgRes))
},
isError = errorMessage != null || storeAlreadyExists,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
lineLimits = TextFieldLineLimits.SingleLine,
)
},
confirmButton = {
TextButton(
onClick = { onCreate(state.text.toString()) },
enabled = !processing && state.text.isNotEmpty() && !storeAlreadyExists,
) {
Text(
text = stringResource(
resource = if (processing) {
MR.strings.extensionStoresScreen_addStore_processing
} else {
MR.strings.action_add
},
),
)
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(MR.strings.action_cancel))
}
},
)
LaunchedEffect(focusRequester) {
// TODO: https://issuetracker.google.com/issues/204502668
delay(0.1.seconds)
focusRequester.requestFocus()
}
}
@Composable
fun ExtensionStoreDeleteDialog(
onDismissRequest: () -> Unit,
onDelete: () -> Unit,
storeName: String,
storeIndexUrl: String,
) {
AlertDialog(
onDismissRequest = onDismissRequest,
title = {
Text(text = stringResource(MR.strings.extensionStoresScreen_deleteStore_title))
},
text = {
Text(text = stringResource(MR.strings.extensionStoresScreen_deleteStore_body, storeName, storeIndexUrl))
},
confirmButton = {
TextButton(onClick = {
onDelete()
onDismissRequest()
}) {
Text(text = stringResource(MR.strings.action_ok))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(MR.strings.action_cancel))
}
},
)
}
@Composable
fun ExtensionStoreConfirmDialog(
onDismissRequest: () -> Unit,
onCreate: () -> Unit,
storeIndexUrl: String,
storeAlreadyExists: Boolean,
processing: Boolean,
errorMessage: String?,
) {
val state = rememberTextFieldState(initialText = storeIndexUrl)
AlertDialog(
onDismissRequest = onDismissRequest,
title = {
Text(text = stringResource(MR.strings.extensionStoresScreen_addStore_title))
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(text = stringResource(MR.strings.extensionStoresScreen_addStoreDeeplink_bodyText))
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
state = state,
readOnly = true,
supportingText = when {
storeAlreadyExists -> {
{
Text(text = stringResource(MR.strings.extensionStoresScreen_addStore_alreadyExists))
}
}
errorMessage != null -> {
{
Text(text = errorMessage)
}
}
else -> null
},
isError = errorMessage != null || storeAlreadyExists,
)
}
},
confirmButton = {
TextButton(onClick = onCreate, enabled = !storeAlreadyExists && !processing) {
Text(
text = stringResource(
resource = if (processing) {
MR.strings.extensionStoresScreen_addStore_processing
} else {
MR.strings.action_add
},
),
)
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(MR.strings.action_cancel))
}
},
)
}

View file

@ -114,7 +114,7 @@ private class SyncSettingsSelectorModel(
tracking = syncSettings.tracking,
history = syncSettings.history,
appSettings = syncSettings.appSettings,
extensionStores = syncSettings.extensionStores,
extensionRepoSettings = syncSettings.extensionRepoSettings,
sourceSettings = syncSettings.sourceSettings,
privateSettings = syncSettings.privateSettings,
@ -134,7 +134,7 @@ private class SyncSettingsSelectorModel(
tracking = backupOptions.tracking,
history = backupOptions.history,
appSettings = backupOptions.appSettings,
extensionStores = backupOptions.extensionStores,
extensionRepoSettings = backupOptions.extensionRepoSettings,
sourceSettings = backupOptions.sourceSettings,
privateSettings = backupOptions.privateSettings,

View file

@ -13,7 +13,6 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
@ -22,27 +21,19 @@ import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import eu.kanade.presentation.reader.components.ChapterNavigator
import eu.kanade.presentation.util.toDurationString
import eu.kanade.tachiyomi.ui.reader.ReaderViewModel
import eu.kanade.tachiyomi.ui.reader.setting.ReaderOrientation
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode
import eu.kanade.tachiyomi.ui.reader.viewer.Viewer
import eu.kanade.tachiyomi.ui.reader.viewer.pager.R2LPagerViewer
import kotlinx.collections.immutable.ImmutableSet
import tachiyomi.i18n.sy.SYMR
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource
import kotlin.time.DurationUnit
import kotlin.time.toDuration
private val readerBarsSlideAnimationSpec = tween<IntOffset>(200)
private val readerBarsFadeAnimationSpec = tween<Float>(150)
@ -100,9 +91,6 @@ fun ReaderAppBars(
onClickBoostPageHelp: () -> Unit,
navBarType: NavBarType,
currentPageText: String,
seriesReadingProgress: ReaderViewModel.SeriesReadingProgress?,
showSeriesProgress: Boolean,
showSeriesEta: Boolean,
enabledButtons: ImmutableSet<String>,
currentReadingMode: ReadingMode,
dualPageSplitEnabled: Boolean,
@ -262,11 +250,6 @@ fun ReaderAppBars(
// SY <--
)
}
ReaderSeriesProgressInfo(
progress = seriesReadingProgress,
showSeriesProgress = showSeriesProgress,
showSeriesEta = showSeriesEta,
)
ReaderBottomBar(
modifier = Modifier
.fillMaxWidth()
@ -297,61 +280,3 @@ fun ReaderAppBars(
}
}
}
@Composable
private fun ReaderSeriesProgressInfo(
progress: ReaderViewModel.SeriesReadingProgress?,
showSeriesProgress: Boolean,
showSeriesEta: Boolean,
) {
if (progress == null || (!showSeriesProgress && !showSeriesEta)) return
val context = LocalContext.current
val infoParts = buildList {
if (showSeriesProgress) {
add(
stringResource(
SYMR.strings.reader_series_progress_value,
progress.currentChapter,
progress.totalChapters,
),
)
}
if (showSeriesEta) {
val etaText = progress.etaMillis
?.toDuration(DurationUnit.MILLISECONDS)
?.toDurationString(context, fallback = "")
?.takeIf { it.isNotBlank() }
add(
etaText
?.let { stringResource(SYMR.strings.reader_series_eta_value, it) }
?: stringResource(SYMR.strings.reader_series_eta_unknown),
)
}
}
if (infoParts.isEmpty()) return
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = MaterialTheme.padding.medium),
horizontalArrangement = Arrangement.Center,
) {
Text(
text = infoParts.joinToString(separator = ""),
modifier = Modifier
.background(
color = MaterialTheme.colorScheme
.surfaceColorAtElevation(6.dp)
.copy(alpha = 0.92f),
shape = MaterialTheme.shapes.small,
)
.padding(
horizontal = MaterialTheme.padding.small,
vertical = MaterialTheme.padding.extraSmall,
),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface,
)
}
}

View file

@ -61,24 +61,6 @@ internal fun GeneralPage(screenModel: ReaderSettingsScreenModel) {
pref = screenModel.preferences.showPageNumber(),
)
CheckboxItem(
label = stringResource(SYMR.strings.pref_show_series_progress),
pref = screenModel.preferences.showSeriesProgress(),
)
val showSeriesEta by screenModel.preferences.showSeriesEta().collectAsState()
CheckboxItem(
label = stringResource(SYMR.strings.pref_show_series_eta),
pref = screenModel.preferences.showSeriesEta(),
)
if (showSeriesEta) {
CheckboxItem(
label = stringResource(SYMR.strings.pref_persist_series_eta),
pref = screenModel.preferences.persistentSeriesEta(),
)
}
// SY -->
val forceHorizontalSeekbar by screenModel.preferences.forceHorizontalSeekbar().collectAsState()
CheckboxItem(

View file

@ -112,9 +112,6 @@ fun UpdateScreen(
else -> {
val scope = rememberCoroutineScope()
var isRefreshing by remember { mutableStateOf(false) }
// KMK -->
val uiModels = remember(state.items) { state.getUiModel() }
// KMK <--
PullRefresh(
refreshing = isRefreshing,
@ -137,8 +134,8 @@ fun UpdateScreen(
updatesLastUpdatedItem(lastUpdated)
updatesUiItems(
uiModels = state.getUiModel(),
// KMK -->
uiModels = uiModels,
expandedState = state.expandedState,
collapseToggle = collapseToggle,
usePanoramaCover = usePanoramaCover,

View file

@ -171,6 +171,7 @@ internal fun LazyListScope.updatesUiItems(
// KMK -->
UpdateSelectionOptions(
selected = !updatesItem.selected,
userSelected = true,
fromLongPress = true,
isGroup = isLeader && item.isExpandable,
isExpanded = isExpanded,
@ -185,6 +186,7 @@ internal fun LazyListScope.updatesUiItems(
// KMK -->
UpdateSelectionOptions(
selected = !updatesItem.selected,
userSelected = true,
fromLongPress = false,
isGroup = isLeader && item.isExpandable,
isExpanded = isExpanded,
@ -286,8 +288,8 @@ private fun UpdatesUiItem(
.combinedClickable(
onClick = onClick,
onLongClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onLongClick()
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
},
)
.padding(top = if (isLeader) MaterialTheme.padding.small else 0.dp)
@ -309,7 +311,7 @@ private fun UpdatesUiItem(
MangaCoverHide.Book(
modifier = Modifier
.width(UpdateItemWidth),
bgColor = bgColor ?: MaterialTheme.colorScheme.surface.takeIf { selected },
bgColor = bgColor ?: (MaterialTheme.colorScheme.surface.takeIf { selected }),
tint = onBgColor,
size = MangaCover.Size.Medium,
)

View file

@ -80,7 +80,7 @@ fun EhLoginWebViewScreen(
)
is LoadingState.Loading -> {
val animatedProgress by animateFloatAsState(
loadingState.progress,
(loadingState as? LoadingState.Loading)?.progress ?: 1f,
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
label = "webview_loading",
)

View file

@ -6,7 +6,7 @@ import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.data.backup.BackupFileValidator
import eu.kanade.tachiyomi.data.backup.create.creators.CategoriesBackupCreator
import eu.kanade.tachiyomi.data.backup.create.creators.ExtensionStoresBackupCreator
import eu.kanade.tachiyomi.data.backup.create.creators.ExtensionRepoBackupCreator
import eu.kanade.tachiyomi.data.backup.create.creators.FeedBackupCreator
import eu.kanade.tachiyomi.data.backup.create.creators.MangaBackupCreator
import eu.kanade.tachiyomi.data.backup.create.creators.PreferenceBackupCreator
@ -14,7 +14,7 @@ import eu.kanade.tachiyomi.data.backup.create.creators.SavedSearchBackupCreator
import eu.kanade.tachiyomi.data.backup.create.creators.SourcesBackupCreator
import eu.kanade.tachiyomi.data.backup.models.Backup
import eu.kanade.tachiyomi.data.backup.models.BackupCategory
import eu.kanade.tachiyomi.data.backup.models.BackupExtensionStore
import eu.kanade.tachiyomi.data.backup.models.BackupExtensionRepos
import eu.kanade.tachiyomi.data.backup.models.BackupFeed
import eu.kanade.tachiyomi.data.backup.models.BackupManga
import eu.kanade.tachiyomi.data.backup.models.BackupPreference
@ -54,7 +54,7 @@ class BackupCreator(
private val categoriesBackupCreator: CategoriesBackupCreator = CategoriesBackupCreator(),
private val mangaBackupCreator: MangaBackupCreator = MangaBackupCreator(),
private val preferenceBackupCreator: PreferenceBackupCreator = PreferenceBackupCreator(),
private val extensionStoresBackupCreator: ExtensionStoresBackupCreator = ExtensionStoresBackupCreator(),
private val extensionRepoBackupCreator: ExtensionRepoBackupCreator = ExtensionRepoBackupCreator(),
private val sourcesBackupCreator: SourcesBackupCreator = SourcesBackupCreator(),
// KMK -->
private val feedBackupCreator: FeedBackupCreator = FeedBackupCreator(),
@ -101,7 +101,7 @@ class BackupCreator(
backupCategories = backupCategories(options),
backupSources = backupSources(backupManga),
backupPreferences = backupAppPreferences(options),
backupExtensionStores = backupExtensionStores(options),
backupExtensionRepo = backupExtensionRepos(options),
backupSourcePreferences = backupSourcePreferences(options),
// SY -->
@ -159,16 +159,16 @@ class BackupCreator(
return sourcesBackupCreator(mangas)
}
internal fun backupAppPreferences(options: BackupOptions): List<BackupPreference> {
/* KMK --> */ suspend /* KMK <-- */ fun backupAppPreferences(options: BackupOptions): List<BackupPreference> {
if (!options.appSettings) return emptyList()
return preferenceBackupCreator.createApp(includePrivatePreferences = options.privateSettings)
}
internal suspend fun backupExtensionStores(options: BackupOptions): List<BackupExtensionStore> {
if (!options.extensionStores) return emptyList()
suspend fun backupExtensionRepos(options: BackupOptions): List<BackupExtensionRepos> {
if (!options.extensionRepoSettings) return emptyList()
return extensionStoresBackupCreator()
return extensionRepoBackupCreator()
}
fun backupSourcePreferences(options: BackupOptions): List<BackupSourcePreferences> {

View file

@ -14,7 +14,7 @@ data class BackupOptions(
val history: Boolean = true,
val readEntries: Boolean = true,
val appSettings: Boolean = true,
val extensionStores: Boolean = true,
val extensionRepoSettings: Boolean = true,
val sourceSettings: Boolean = true,
val privateSettings: Boolean = false,
// SY -->
@ -31,7 +31,7 @@ data class BackupOptions(
history,
readEntries,
appSettings,
extensionStores,
extensionRepoSettings,
sourceSettings,
privateSettings,
// SY -->
@ -41,7 +41,7 @@ data class BackupOptions(
)
fun canCreate() =
libraryEntries || categories || appSettings || extensionStores || sourceSettings || savedSearchesFeeds
libraryEntries || categories || appSettings || extensionRepoSettings || sourceSettings || savedSearchesFeeds
companion object {
val libraryOptions = persistentListOf(
@ -103,9 +103,9 @@ data class BackupOptions(
setter = { options, enabled -> options.copy(appSettings = enabled) },
),
Entry(
label = MR.strings.extensionStores,
getter = BackupOptions::extensionStores,
setter = { options, enabled -> options.copy(extensionStores = enabled) },
label = MR.strings.extensionRepo_settings,
getter = BackupOptions::extensionRepoSettings,
setter = { options, enabled -> options.copy(extensionRepoSettings = enabled) },
),
Entry(
label = MR.strings.source_settings,
@ -128,7 +128,7 @@ data class BackupOptions(
history = array[4],
readEntries = array[5],
appSettings = array[6],
extensionStores = array[7],
extensionRepoSettings = array[7],
sourceSettings = array[8],
privateSettings = array[9],
// SY -->

View file

@ -0,0 +1,17 @@
package eu.kanade.tachiyomi.data.backup.create.creators
import eu.kanade.tachiyomi.data.backup.models.BackupExtensionRepos
import eu.kanade.tachiyomi.data.backup.models.backupExtensionReposMapper
import mihon.domain.extensionrepo.interactor.GetExtensionRepo
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class ExtensionRepoBackupCreator(
private val getExtensionRepos: GetExtensionRepo = Injekt.get(),
) {
suspend operator fun invoke(): List<BackupExtensionRepos> {
return getExtensionRepos.getAll()
.map(backupExtensionReposMapper)
}
}

View file

@ -1,17 +0,0 @@
package eu.kanade.tachiyomi.data.backup.create.creators
import eu.kanade.tachiyomi.data.backup.models.BackupExtensionStore
import eu.kanade.tachiyomi.data.backup.models.backupExtensionStoreMapper
import mihon.domain.extension.interactor.GetExtensionStores
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class ExtensionStoresBackupCreator(
private val getExtensionStores: GetExtensionStores = Injekt.get(),
) {
suspend operator fun invoke(): List<BackupExtensionStore> {
return getExtensionStores.get()
.map(backupExtensionStoreMapper)
}
}

View file

@ -108,12 +108,7 @@ class MangaBackupCreator(
if (historyByMangaId.isNotEmpty()) {
val history = historyByMangaId.map { history ->
val chapter = handler.awaitOne { chaptersQueries.getChapterById(history.chapterId) }
BackupHistory(
url = chapter.url,
lastRead = history.readAt?.time ?: 0L,
readDuration = history.readDuration,
readPages = history.readPages,
)
BackupHistory(chapter.url, history.readAt?.time ?: 0L, history.readDuration)
}
if (history.isNotEmpty()) {
mangaObject.history = history

View file

@ -11,7 +11,7 @@ data class Backup(
@ProtoNumber(101) var backupSources: List<BackupSource> = emptyList(),
@ProtoNumber(104) var backupPreferences: List<BackupPreference> = emptyList(),
@ProtoNumber(105) var backupSourcePreferences: List<BackupSourcePreferences> = emptyList(),
@ProtoNumber(106) var backupExtensionStores: List<BackupExtensionStore> = emptyList(),
@ProtoNumber(106) var backupExtensionRepo: List<BackupExtensionRepos> = emptyList(),
// SY specific values
@ProtoNumber(600) var backupSavedSearches: List<BackupSavedSearch> = emptyList(),
// KMK -->

View file

@ -0,0 +1,24 @@
package eu.kanade.tachiyomi.data.backup.models
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
import mihon.domain.extensionrepo.model.ExtensionRepo
@Serializable
class BackupExtensionRepos(
@ProtoNumber(1) var baseUrl: String,
@ProtoNumber(2) var name: String,
@ProtoNumber(3) var shortName: String?,
@ProtoNumber(4) var website: String,
@ProtoNumber(5) var signingKeyFingerprint: String,
)
val backupExtensionReposMapper = { repo: ExtensionRepo ->
BackupExtensionRepos(
baseUrl = repo.baseUrl,
name = repo.name,
shortName = repo.shortName,
website = repo.website,
signingKeyFingerprint = repo.signingKeyFingerprint,
)
}

View file

@ -1,28 +0,0 @@
package eu.kanade.tachiyomi.data.backup.models
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
import mihon.domain.extension.model.ExtensionStore
@Serializable
class BackupExtensionStore(
@ProtoNumber(1) var indexUrl: String,
@ProtoNumber(2) var name: String,
@ProtoNumber(3) var badgeLabel: String?,
@ProtoNumber(5) var signingKey: String,
@ProtoNumber(4) var contactWebsite: String,
@ProtoNumber(6) var contactDiscord: String?,
@ProtoNumber(7) var isLegacy: Boolean?,
)
val backupExtensionStoreMapper = { store: ExtensionStore ->
BackupExtensionStore(
indexUrl = store.indexUrl,
name = store.name,
badgeLabel = store.badgeLabel,
signingKey = store.signingKey,
contactWebsite = store.contact.website,
contactDiscord = store.contact.discord,
isLegacy = store.isLegacy,
)
}

View file

@ -10,13 +10,11 @@ data class BackupHistory(
@ProtoNumber(1) var url: String,
@ProtoNumber(2) var lastRead: Long,
@ProtoNumber(3) var readDuration: Long = 0,
@ProtoNumber(4) var readPages: Long = 0,
) {
fun getHistoryImpl(): History {
return History.create().copy(
readAt = Date(lastRead),
readDuration = readDuration,
readPages = readPages,
)
}
}

View file

@ -5,14 +5,14 @@ import android.net.Uri
import eu.kanade.tachiyomi.data.backup.BackupDecoder
import eu.kanade.tachiyomi.data.backup.BackupNotifier
import eu.kanade.tachiyomi.data.backup.models.BackupCategory
import eu.kanade.tachiyomi.data.backup.models.BackupExtensionStore
import eu.kanade.tachiyomi.data.backup.models.BackupExtensionRepos
import eu.kanade.tachiyomi.data.backup.models.BackupFeed
import eu.kanade.tachiyomi.data.backup.models.BackupManga
import eu.kanade.tachiyomi.data.backup.models.BackupPreference
import eu.kanade.tachiyomi.data.backup.models.BackupSavedSearch
import eu.kanade.tachiyomi.data.backup.models.BackupSourcePreferences
import eu.kanade.tachiyomi.data.backup.restore.restorers.CategoriesRestorer
import eu.kanade.tachiyomi.data.backup.restore.restorers.ExtensionStoreRestorer
import eu.kanade.tachiyomi.data.backup.restore.restorers.ExtensionRepoRestorer
import eu.kanade.tachiyomi.data.backup.restore.restorers.FeedRestorer
import eu.kanade.tachiyomi.data.backup.restore.restorers.MangaRestorer
import eu.kanade.tachiyomi.data.backup.restore.restorers.PreferenceRestorer
@ -38,7 +38,7 @@ class BackupRestorer(
private val categoriesRestorer: CategoriesRestorer = CategoriesRestorer(),
private val preferenceRestorer: PreferenceRestorer = PreferenceRestorer(context),
private val extensionStoreRestorer: ExtensionStoreRestorer = ExtensionStoreRestorer(),
private val extensionRepoRestorer: ExtensionRepoRestorer = ExtensionRepoRestorer(),
private val mangaRestorer: MangaRestorer = MangaRestorer(isSync),
// SY -->
private val savedSearchRestorer: SavedSearchRestorer = SavedSearchRestorer(),
@ -96,8 +96,8 @@ class BackupRestorer(
if (options.appSettings) {
restoreAmount += 1
}
if (options.extensionStores) {
restoreAmount += backup.backupExtensionStores.size
if (options.extensionRepoSettings) {
restoreAmount += backup.backupExtensionRepo.size
}
if (options.sourceSettings) {
restoreAmount += 1
@ -126,8 +126,8 @@ class BackupRestorer(
if (options.libraryEntries) {
restoreManga(backup.backupManga, if (options.categories) backup.backupCategories else emptyList())
}
if (options.extensionStores) {
restoreExtensionStores(backup.backupExtensionStores)
if (options.extensionRepoSettings) {
restoreExtensionRepos(backup.backupExtensionRepo)
}
// TODO: optionally trigger online library + tracker update
@ -248,25 +248,23 @@ class BackupRestorer(
}
}
private fun CoroutineScope.restoreExtensionStores(
backupExtensionStores: List<BackupExtensionStore>,
private fun CoroutineScope.restoreExtensionRepos(
backupExtensionRepo: List<BackupExtensionRepos>,
) = launch {
backupExtensionStores
backupExtensionRepo
.forEach {
ensureActive()
try {
extensionStoreRestorer(it)
extensionRepoRestorer(it)
} catch (e: Exception) {
errors.add(Date() to "Error adding extension store: ${it.name} : ${e.message}")
errors.add(Date() to "Error Adding Repo: ${it.name} : ${e.message}")
}
restoreProgress += 1
// KMK -->
with(notifier) {
// KMK <--
showRestoreProgress(
context.stringResource(MR.strings.extensionStores),
context.stringResource(MR.strings.extensionRepo_settings),
restoreProgress,
restoreAmount,
isSync,

View file

@ -9,7 +9,7 @@ data class RestoreOptions(
val libraryEntries: Boolean = true,
val categories: Boolean = true,
val appSettings: Boolean = true,
val extensionStores: Boolean = true,
val extensionRepoSettings: Boolean = true,
val sourceSettings: Boolean = true,
// SY -->
val savedSearchesFeeds: Boolean = true,
@ -20,7 +20,7 @@ data class RestoreOptions(
libraryEntries,
categories,
appSettings,
extensionStores,
extensionRepoSettings,
sourceSettings,
// SY -->
savedSearchesFeeds,
@ -31,7 +31,7 @@ data class RestoreOptions(
libraryEntries ||
categories ||
appSettings ||
extensionStores ||
extensionRepoSettings ||
sourceSettings /* SY --> */ ||
savedSearchesFeeds /* SY <-- */
@ -53,9 +53,9 @@ data class RestoreOptions(
setter = { options, enabled -> options.copy(appSettings = enabled) },
),
Entry(
label = MR.strings.extensionStores,
getter = RestoreOptions::extensionStores,
setter = { options, enabled -> options.copy(extensionStores = enabled) },
label = MR.strings.extensionRepo_settings,
getter = RestoreOptions::extensionRepoSettings,
setter = { options, enabled -> options.copy(extensionRepoSettings = enabled) },
),
Entry(
label = MR.strings.source_settings,
@ -77,7 +77,7 @@ data class RestoreOptions(
libraryEntries = array[0],
categories = array[1],
appSettings = array[2],
extensionStores = array[3],
extensionRepoSettings = array[3],
sourceSettings = array[4],
// SY -->
savedSearchesFeeds = array[5],

View file

@ -0,0 +1,40 @@
package eu.kanade.tachiyomi.data.backup.restore.restorers
import eu.kanade.tachiyomi.data.backup.models.BackupExtensionRepos
import mihon.domain.extensionrepo.interactor.GetExtensionRepo
import tachiyomi.data.DatabaseHandler
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class ExtensionRepoRestorer(
private val handler: DatabaseHandler = Injekt.get(),
private val getExtensionRepos: GetExtensionRepo = Injekt.get(),
) {
suspend operator fun invoke(
backupRepo: BackupExtensionRepos,
) {
val dbRepos = getExtensionRepos.getAll()
val existingReposBySHA = dbRepos.associateBy { it.signingKeyFingerprint }
val existingReposByUrl = dbRepos.associateBy { it.baseUrl }
val urlExists = existingReposByUrl[backupRepo.baseUrl]
val shaExists = existingReposBySHA[backupRepo.signingKeyFingerprint]
if (urlExists != null && urlExists.signingKeyFingerprint != backupRepo.signingKeyFingerprint) {
error("Already Exists with different signing key fingerprint")
} else if (shaExists != null) {
error("${shaExists.name} has the same signing key fingerprint")
} else {
handler.await {
extension_reposQueries.insert(
backupRepo.baseUrl,
backupRepo.name,
backupRepo.shortName,
backupRepo.website,
backupRepo.signingKeyFingerprint,
)
}
}
}
}

View file

@ -1,32 +0,0 @@
package eu.kanade.tachiyomi.data.backup.restore.restorers
import eu.kanade.tachiyomi.data.backup.models.BackupExtensionStore
import tachiyomi.data.Database
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class ExtensionStoreRestorer(
private val database: Database = Injekt.get(),
) {
suspend operator fun invoke(
backupStore: BackupExtensionStore,
) {
// KMK -->
val indexUrl = if (backupStore.isLegacy == null) {
backupStore.indexUrl.removeSuffix("/index.min.json").removeSuffix("/index.json") + "/repo.json"
} else {
backupStore.indexUrl
}
// KMK <--
database.extension_storeQueries.upsert(
indexUrl = indexUrl,
name = backupStore.name,
badgeLabel = backupStore.badgeLabel ?: backupStore.name,
signingKey = backupStore.signingKey,
contactWebsite = backupStore.contactWebsite,
contactDiscord = backupStore.contactDiscord,
isLegacy = backupStore.isLegacy ?: true,
)
}
}

View file

@ -427,7 +427,6 @@ class MangaRestorer(
.takeIf { it > 0L }
?.let { Date(it) },
readDuration = max(item.readDuration, dbHistory.time_read) - dbHistory.time_read,
readPages = max(item.readPages, dbHistory.pages_read) - dbHistory.pages_read,
)
}
@ -438,7 +437,6 @@ class MangaRestorer(
it.chapterId,
it.readAt,
it.readDuration,
it.readPages,
)
}
}

View file

@ -58,7 +58,6 @@ import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import logcat.LogPriority
import mihon.domain.chapter.interactor.FilterChaptersForDownload
import mihon.domain.chapter.interactor.GetChaptersForLibraryUpdateDownload
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.preference.getAndSet
import tachiyomi.core.common.util.lang.withIOContext
@ -120,7 +119,6 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
private val syncChaptersWithSource: SyncChaptersWithSource = Injekt.get()
private val fetchInterval: FetchInterval = Injekt.get()
private val filterChaptersForDownload: FilterChaptersForDownload = Injekt.get()
private val getChaptersForLibraryUpdateDownload: GetChaptersForLibraryUpdateDownload = Injekt.get()
// SY -->
private val getFavorites: GetFavorites = Injekt.get()
@ -467,12 +465,6 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
// Convert to the manga that contains new chapters
newUpdates.add(manga to newChapters.toTypedArray())
}
val libraryUpdateChapters = getChaptersForLibraryUpdateDownload.await(manga)
if (libraryUpdateChapters.isNotEmpty()) {
downloadChapters(manga, libraryUpdateChapters)
hasDownloads.store(true)
}
clearErrorFromDB(mangaId = manga.id)
} catch (e: Throwable) {
val errorMessage = when (e) {
@ -501,10 +493,9 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
if (newUpdates.isNotEmpty()) {
notifier.showUpdateNotifications(newUpdates)
}
if (hasDownloads.load()) {
downloadManager.startDownloads()
if (hasDownloads.load()) {
downloadManager.startDownloads()
}
}
if (failedUpdates.isNotEmpty()) {
@ -757,6 +748,10 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
private const val KEY_MANGA_IDS = "manga_ids"
// KMK <--
fun cancelAllWorks(context: Context) {
context.workManager.cancelAllWorkByTag(TAG)
}
fun setupTask(
context: Context,
prefInterval: Int? = null,
@ -770,19 +765,16 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
} else {
NetworkType.CONNECTED
}
val networkRequest = NetworkRequest.Builder().apply {
removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
if (DEVICE_ONLY_ON_WIFI in restrictions) {
addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
}
if (DEVICE_NETWORK_NOT_METERED in restrictions) {
addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
}
val networkRequestBuilder = NetworkRequest.Builder()
if (DEVICE_ONLY_ON_WIFI in restrictions) {
networkRequestBuilder.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
}
if (DEVICE_NETWORK_NOT_METERED in restrictions) {
networkRequestBuilder.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
}
.build()
val constraints = Constraints.Builder()
// 'networkRequest' only applies to Android 9+, otherwise 'networkType' is used
.setRequiredNetworkRequest(networkRequest, networkType)
.setRequiredNetworkRequest(networkRequestBuilder.build(), networkType)
.setRequiresCharging(DEVICE_CHARGING in restrictions)
.setRequiresBatteryNotLow(true)
.build()

View file

@ -57,7 +57,7 @@ class SyncManager(
SYNCYOMI(1),
GOOGLE_DRIVE(2),
// KMK -->
WEB_DAV(3),
WebDAV(3),
// KMK <--
;
@ -88,7 +88,7 @@ class SyncManager(
chapters = syncOptions.chapters,
tracking = syncOptions.tracking,
history = syncOptions.history,
extensionStores = syncOptions.extensionStores,
extensionRepoSettings = syncOptions.extensionRepoSettings,
appSettings = syncOptions.appSettings,
sourceSettings = syncOptions.sourceSettings,
privateSettings = syncOptions.privateSettings,
@ -107,8 +107,8 @@ class SyncManager(
backupCategories = backupCreator.backupCategories(backupOptions),
backupSources = backupCreator.backupSources(backupManga),
backupPreferences = backupCreator.backupAppPreferences(backupOptions),
backupExtensionRepo = backupCreator.backupExtensionRepos(backupOptions),
backupSourcePreferences = backupCreator.backupSourcePreferences(backupOptions),
backupExtensionStores = backupCreator.backupExtensionStores(backupOptions),
// SY -->
backupSavedSearches = backupCreator.backupSavedSearches(backupOptions),
@ -142,7 +142,7 @@ class SyncManager(
}
// KMK -->
SyncService.WEB_DAV -> {
SyncService.WebDAV -> {
WebDavSyncService(context, json, syncPreferences, notifier)
}
// KMK <--
@ -192,7 +192,7 @@ class SyncManager(
backupSources = remoteBackup.backupSources,
backupPreferences = remoteBackup.backupPreferences,
backupSourcePreferences = remoteBackup.backupSourcePreferences,
backupExtensionStores = remoteBackup.backupExtensionStores,
backupExtensionRepo = remoteBackup.backupExtensionRepo,
// SY -->
backupSavedSearches = remoteBackup.backupSavedSearches,
@ -222,7 +222,7 @@ class SyncManager(
appSettings = true,
sourceSettings = true,
libraryEntries = true,
extensionStores = true,
extensionRepoSettings = true,
),
)

View file

@ -5,14 +5,8 @@ import eu.kanade.domain.sync.SyncPreferences
import eu.kanade.tachiyomi.data.backup.models.Backup
import eu.kanade.tachiyomi.data.sync.SyncNotifier
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.PUT
import eu.kanade.tachiyomi.network.await
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.protobuf.ProtoBuf
@ -27,6 +21,7 @@ import tachiyomi.core.common.i18n.stringResource
import tachiyomi.i18n.MR
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.concurrent.TimeUnit
class SyncYomiSyncService(
context: Context,
@ -37,34 +32,9 @@ class SyncYomiSyncService(
private val protoBuf: ProtoBuf = Injekt.get(),
) : SyncService(context, json, syncPreferences) {
// KMK -->
companion object {
private val client by lazy { OkHttpClient() }
}
// KMK <--
private class SyncYomiException(message: String?) : Exception(message)
@Serializable
private data class SyncEvent(
val event: SyncEventStatus,
@SerialName("device_name")
val deviceName: String? = null,
val message: String? = null,
)
@Serializable
private enum class SyncEventStatus {
SYNC_STARTED,
SYNC_SUCCESS,
SYNC_FAILED,
SYNC_ERROR,
SYNC_CANCELLED,
}
override suspend fun doSync(syncData: SyncData): Backup? {
reportSyncEvent(SyncEventStatus.SYNC_STARTED)
try {
val (remoteData, etag) = pullSyncData()
@ -82,26 +52,11 @@ class SyncYomiSyncService(
syncData
}
val success = pushSyncData(finalSyncData, etag)
if (success) {
reportSyncEvent(SyncEventStatus.SYNC_SUCCESS)
} else {
reportSyncEvent(SyncEventStatus.SYNC_FAILED, "Failed to push sync data")
// KMK -->
return null
// KMK <--
}
pushSyncData(finalSyncData, etag)
return finalSyncData.backup
} catch (e: Exception) {
if (e is CancellationException) {
reportSyncEvent(SyncEventStatus.SYNC_CANCELLED, e.message)
throw e
}
logcat(LogPriority.ERROR) { "Error syncing: ${e.message}" }
notifier.showSyncError(e.message)
reportSyncEvent(SyncEventStatus.SYNC_ERROR, e.message)
return null
}
}
@ -123,6 +78,7 @@ class SyncYomiSyncService(
headers = headers,
)
val client = OkHttpClient()
val response = client.newCall(downloadRequest).await()
if (response.code == HttpStatus.SC_NOT_MODIFIED) {
@ -167,12 +123,13 @@ class SyncYomiSyncService(
/**
* Return true if update success
*/
private suspend fun pushSyncData(syncData: SyncData, eTag: String): Boolean {
val backup = syncData.backup ?: return true
private suspend fun pushSyncData(syncData: SyncData, eTag: String) {
val backup = syncData.backup ?: return
val host = syncPreferences.clientHost().get()
val apiKey = syncPreferences.clientAPIKey().get()
val uploadUrl = "$host/api/sync/content"
val timeout = 30L
val headersBuilder = Headers.Builder().add("X-API-Token", apiKey)
if (eTag.isNotEmpty()) {
@ -180,6 +137,13 @@ class SyncYomiSyncService(
}
val headers = headersBuilder.build()
// Set timeout to 30 seconds
val client = OkHttpClient.Builder()
.connectTimeout(timeout, TimeUnit.SECONDS)
.readTimeout(timeout, TimeUnit.SECONDS)
.writeTimeout(timeout, TimeUnit.SECONDS)
.build()
val byteArray = protoBuf.encodeToByteArray(Backup.serializer(), backup)
if (byteArray.isEmpty()) {
throw IllegalStateException(context.stringResource(MR.strings.empty_backup_error))
@ -192,58 +156,20 @@ class SyncYomiSyncService(
body = body,
)
client.newCall(uploadRequest).await()
// KMK -->
.use { response ->
// KMK <--
if (response.isSuccessful) {
val newETag = response.headers["ETag"]
.takeIf { it?.isNotEmpty() == true } ?: throw SyncYomiException("Missing ETag")
syncPreferences.lastSyncEtag().set(newETag)
logcat(LogPriority.DEBUG) { "SyncYomi sync completed" }
return true
} else if (response.code == HttpStatus.SC_PRECONDITION_FAILED) {
// other clients updated remote data, will try next time
logcat(LogPriority.DEBUG) { "SyncYomi sync failed with 412" }
return false
} else {
val responseBody = response.body.string()
notifier.showSyncError("Failed to upload sync data: $responseBody")
logcat(LogPriority.ERROR) { "SyncError: $responseBody" }
return false
}
}
}
val response = client.newCall(uploadRequest).await()
private suspend fun reportSyncEvent(event: SyncEventStatus, message: String? = null) {
withContext(NonCancellable) {
try {
val host = syncPreferences.clientHost().get()
val apiKey = syncPreferences.clientAPIKey().get()
val url = "$host/api/sync/event"
val headersBuilder = Headers.Builder().add("X-API-Token", apiKey)
val headers = headersBuilder.build()
val bodyObj = SyncEvent(
event = event,
deviceName = android.os.Build.MODEL,
message = message,
)
val jsonBody = json.encodeToString(SyncEvent.serializer(), bodyObj)
val requestBody = jsonBody.toRequestBody("application/json; charset=utf-8".toMediaType())
val request = POST(
url = url,
headers = headers,
body = requestBody,
)
client.newCall(request).await().close()
} catch (e: Exception) {
logcat(LogPriority.ERROR) { "Failed to report sync event: ${e.message}" }
}
if (response.isSuccessful) {
val newETag = response.headers["ETag"]
.takeIf { it?.isNotEmpty() == true } ?: throw SyncYomiException("Missing ETag")
syncPreferences.lastSyncEtag().set(newETag)
logcat(LogPriority.DEBUG) { "SyncYomi sync completed" }
} else if (response.code == HttpStatus.SC_PRECONDITION_FAILED) {
// other clients updated remote data, will try next time
logcat(LogPriority.DEBUG) { "SyncYomi sync failed with 412" }
} else {
val responseBody = response.body.string()
notifier.showSyncError("Failed to upload sync data: $responseBody")
logcat(LogPriority.ERROR) { "SyncError: $responseBody" }
}
}
}

View file

@ -40,7 +40,7 @@ class WebDavSyncService(
private val url: String = syncPreferences.webDavUrl().get().trim()
private val folder: String = syncPreferences.webDavFolder().get().trim('/')
private val username: String = syncPreferences.webDavUsername().get().trim()
private val password: String = syncPreferences.webDavPassword().get()
private val password: String = syncPreferences.webDavPassword().get().trim()
private val credentials: String = Credentials.basic(username, password)
private fun buildWebDavFileUrl(fileName: String = "backup.proto"): String {

View file

@ -5,7 +5,6 @@ import androidx.core.net.toUri
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.anilist.dto.ALAddMangaResult
import eu.kanade.tachiyomi.data.track.anilist.dto.ALCurrentUserResult
import eu.kanade.tachiyomi.data.track.anilist.dto.ALError
import eu.kanade.tachiyomi.data.track.anilist.dto.ALIdSearchResult
import eu.kanade.tachiyomi.data.track.anilist.dto.ALMangaMetadata
import eu.kanade.tachiyomi.data.track.anilist.dto.ALOAuth
@ -27,7 +26,6 @@ import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import tachiyomi.core.common.util.lang.withIOContext
import uy.kohesive.injekt.injectLazy
import java.time.Instant
@ -45,25 +43,6 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
.rateLimit(permits = 85, period = 1.minutes)
.build()
// KMK -->
private fun Response.parseALError() {
val bodyString = peekBody(1024 * 1024).string()
val errorObj = try {
json.decodeFromString<ALError>(bodyString)
} catch (_: Exception) {
null
}
errorObj?.errors?.firstOrNull()?.let {
val msg = it.message
if (msg.contains("Invalid token") || it.status == 401) {
throw Exception("AniList token expired, please login again")
}
throw Exception(msg)
}
}
// KMK <--
suspend fun addLibManga(track: Track): Track {
return withIOContext {
val query = $$"""
@ -92,9 +71,6 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
),
)
.awaitSuccess()
// KMK -->
.also { it.parseALError() }
// KMK <--
.parseAs<ALAddMangaResult>()
.let {
track.library_id = it.data.entry.id
@ -136,9 +112,6 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
}
authClient.newCall(POST(API_URL, body = payload.toString().toRequestBody(jsonMime)))
.awaitSuccess()
// KMK -->
.use { it.parseALError() }
// KMK <--
track
}
}
@ -161,9 +134,6 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
}
authClient.newCall(POST(API_URL, body = payload.toString().toRequestBody(jsonMime)))
.awaitSuccess()
// KMK -->
.use { it.parseALError() }
// KMK <--
}
}
@ -222,9 +192,6 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
),
)
.awaitSuccess()
// KMK -->
.also { it.parseALError() }
// KMK <--
.parseAs<ALSearchResult>()
.data.page.media
.map { it.toALManga().toTrack() }
@ -304,9 +271,6 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
),
)
.awaitSuccess()
// KMK -->
.also { it.parseALError() }
// KMK <--
.parseAs<ALUserListMangaQueryResult>()
.data.page.mediaList
.map { it.toALUserManga() }
@ -348,9 +312,6 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
),
)
.awaitSuccess()
// KMK -->
.also { it.parseALError() }
// KMK <--
.parseAs<ALCurrentUserResult>()
.let {
val viewer = it.data.viewer
@ -362,9 +323,9 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
suspend fun getMangaMetadata(track: DomainTrack): TrackMangaMetadata {
return withIOContext {
val query = $$"""
|query ($mangaId: Int!) {
|Media (id: $mangaId) {
val query = """
|query (${'$'}mangaId: Int!) {
|Media (id: ${'$'}mangaId) {
|id
|title {
|userPreferred
@ -404,12 +365,9 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
),
)
.awaitSuccess()
// KMK -->
.also { it.parseALError() }
// KMK <--
.parseAs<ALMangaMetadata>()
.let { metadata ->
val media = metadata.data.media
.let {
val media = it.data.media
TrackMangaMetadata(
remoteId = media.id,
title = media.title.userPreferred,
@ -434,9 +392,9 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
// SY -->
suspend fun searchById(id: String): TrackSearch {
return withIOContext {
val query = $$"""
|query ($mangaId: Int!) {
|Media (id: $mangaId) {
val query = """
|query (${'$'}mangaId: Int!) {
|Media (id: ${'$'}mangaId) {
|id
|title {
|userPreferred
@ -472,9 +430,6 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
),
)
.awaitSuccess()
// KMK -->
.also { it.parseALError() }
// KMK <--
.parseAs<ALIdSearchResult>()
.data.media
.toALManga()

View file

@ -1,14 +0,0 @@
package eu.kanade.tachiyomi.data.track.anilist.dto
import kotlinx.serialization.Serializable
@Serializable
data class ALError(
val errors: List<ALErrorItem>? = null,
)
@Serializable
data class ALErrorItem(
val message: String,
val status: Int? = null,
)

View file

@ -14,9 +14,7 @@ import eu.kanade.tachiyomi.data.track.myanimelist.dto.MALSearchResult
import eu.kanade.tachiyomi.data.track.myanimelist.dto.MALUser
import eu.kanade.tachiyomi.network.DELETE
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.HttpException
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.parseAs
import eu.kanade.tachiyomi.util.PkceUtil
@ -126,23 +124,8 @@ class MyAnimeListApi(
.put(formBodyBuilder.build())
.build()
with(json) {
val response = authClient
.newCall(request)
.await()
if (!response.isSuccessful) {
if (response.body.string().contains("invalid_content")) {
// MAL returns unapproved titles in search but does not allow adding them to the list
// returns 400 with this body: {"message":"Invalid content","error":"invalid_content"}
// These unapproved titles cannot be filtered out in search and are also returned by the
// endpoint we use for id prefix search
throw MALTitleNotApproved()
} else {
throw HttpException(response.code)
}
}
response
authClient.newCall(request)
.awaitSuccess()
.parseAs<MALListItemStatus>()
.let { parseMangaItem(it, track) }
}

View file

@ -80,6 +80,5 @@ class MyAnimeListInterceptor(private val myanimelist: MyAnimeList) : Interceptor
}
}
class MALTitleNotApproved : IOException("MAL: This title can't be added because it is waiting for approval.")
class MALTokenRefreshFailed : IOException("MAL: Failed to refresh account token")
class MALTokenExpired : IOException("MAL: Login has expired")

View file

@ -93,17 +93,13 @@ class AppUpdateChecker(
// KMK <--
}
private const val RELEASES_WEB_BASE_URL = "https://192.168.1.155:3030"
private const val FORK_RELEASE_REPO = "copilot/komikku"
private const val FORK_PREVIEW_RELEASE_REPO = "copilot/komikku-preview"
val GITHUB_REPO: String by lazy { getGithubRepo() }
fun getGithubRepo(peekIntoPreview: Boolean = false): String =
if (isPreviewBuildType || peekIntoPreview) {
FORK_PREVIEW_RELEASE_REPO
"komikku-app/komikku-preview"
} else {
FORK_RELEASE_REPO
"komikku-app/komikku"
}
val RELEASE_TAG: String by lazy { getReleaseTag() }
@ -115,4 +111,4 @@ fun getReleaseTag(peekIntoPreview: Boolean = false): String =
"v${BuildConfig.VERSION_NAME}"
}
val RELEASE_URL = "$RELEASES_WEB_BASE_URL/$GITHUB_REPO/releases/tag/$RELEASE_TAG"
val RELEASE_URL = "https://github.com/$GITHUB_REPO/releases/tag/$RELEASE_TAG"

View file

@ -128,8 +128,6 @@ class AppModule(val app: Application) : InjektModule {
Json {
ignoreUnknownKeys = true
explicitNulls = false
// Gitea/Forgejo often uses JSON null for empty release body or assets array.
coerceInputValues = true
}
}
addSingletonFactory {

View file

@ -19,7 +19,6 @@ import tachiyomi.domain.backup.service.BackupPreferences
import tachiyomi.domain.download.service.DownloadPreferences
import tachiyomi.domain.history.service.HistoryPreferences
import tachiyomi.domain.library.service.LibraryPreferences
import tachiyomi.domain.reading.service.ReadingEtaPreferences
import tachiyomi.domain.storage.service.StoragePreferences
import tachiyomi.domain.updates.service.UpdatesPreferences
import uy.kohesive.injekt.api.InjektModule
@ -62,9 +61,6 @@ class PreferenceModule(val app: Application) : InjektModule {
addSingletonFactory {
ReaderPreferences(get())
}
addSingletonFactory {
ReadingEtaPreferences(get())
}
addSingletonFactory {
TrackPreferences(get())
}

View file

@ -200,7 +200,7 @@ class ExtensionManager(
availableExtensionMapFlow.value = extensions.associateBy {
it.pkgName +
// KMK -->
"_${it.signatureHash}"
":${it.signatureHash}"
// KMK <--
}
updatedInstalledExtensionsStatuses(extensions)
@ -273,14 +273,14 @@ class ExtensionManager(
// Ext found: Update installed extensions with new information from repo
// Also clear isObsolete and set new repo Name if needed
val hasUpdate = extension.updateExists(availableExt)
// KMK -->
installedExtensionsMap[pkgName] = extension.copy(
hasUpdate = hasUpdate,
store = availableExt.store,
repoUrl = availableExt.repoUrl,
// KMK -->
isObsolete = false,
storeName = extension.storeName ?: availableExt.storeName,
repoName = extension.repoName ?: availableExt.repoName,
// KMK <--
)
// KMK <--
changed = true
}
}
@ -298,7 +298,7 @@ class ExtensionManager(
* @param extension The extension to be installed.
*/
fun installExtension(extension: Extension.Available): Flow<InstallStep> {
return installer.downloadAndInstall(extension.apkUrl, extension)
return installer.downloadAndInstall(api.getApkUrl(extension), extension)
}
/**
@ -312,7 +312,7 @@ class ExtensionManager(
val availableExt = availableExtensionMapFlow.value[
extension.pkgName +
// KMK -->
"_${extension.signatureHash}",
":${extension.signatureHash}",
// KMK <--
] ?: return emptyFlow()
return installExtension(availableExt)
@ -322,7 +322,7 @@ class ExtensionManager(
installer.cancelInstall(
extension.pkgName +
// KMK -->
"_${extension.signatureHash}",
":${extension.signatureHash}",
// KMK <--
)
}

View file

@ -6,13 +6,24 @@ import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.LoadResult
import eu.kanade.tachiyomi.extension.util.ExtensionLoader
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.parseAs
import exh.source.BlacklistedSources
import exh.source.ExhPreferences
import mihon.domain.extension.interactor.UpdateExtensionStores
import mihon.domain.extension.repository.ExtensionStoreRepository
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import logcat.LogPriority
import mihon.domain.extensionrepo.interactor.GetExtensionRepo
import mihon.domain.extensionrepo.interactor.UpdateExtensionRepo
import mihon.domain.extensionrepo.model.ExtensionRepo
import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.system.logcat
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
@ -21,16 +32,19 @@ import kotlin.time.Duration.Companion.days
internal class ExtensionApi {
private val repository: ExtensionStoreRepository by injectLazy()
private val networkService: NetworkHelper by injectLazy()
private val preferenceStore: PreferenceStore by injectLazy()
private val updateExtensionStores: UpdateExtensionStores by injectLazy()
private val getExtensionRepo: GetExtensionRepo by injectLazy()
private val updateExtensionRepo: UpdateExtensionRepo by injectLazy()
private val extensionManager: ExtensionManager by injectLazy()
// SY -->
private val sourcePreferences: SourcePreferences by injectLazy()
// SY <--
private val json: Json by injectLazy()
private val lastExtCheck: Preference<Long> by lazy {
preferenceStore.getLong(Preference.appStateKey("last_ext_check"), 0)
}
@ -40,11 +54,37 @@ internal class ExtensionApi {
val disabledRepos = sourcePreferences.disabledRepos().get()
// KMK <--
return withIOContext {
repository.fetchExtensions(
getExtensionRepo.getAll()
// KMK -->
disabledRepos,
.filterNot { it.baseUrl in disabledRepos }
// KMK <--
)
.map { async { getExtensions(it) } }
.awaitAll()
.flatten()
}
}
private suspend fun getExtensions(extRepo: ExtensionRepo): List<Extension.Available> {
val repoBaseUrl = extRepo.baseUrl
return try {
val response = networkService.client
.newCall(GET("$repoBaseUrl/index.min.json"))
.awaitSuccess()
with(json) {
response
.parseAs<List<ExtensionJsonObject>>()
.toExtensions(
repoBaseUrl,
// KMK -->
signature = extRepo.signingKeyFingerprint,
repoName = extRepo.shortName ?: extRepo.name,
// KMK <--
)
}
} catch (e: Throwable) {
logcat(LogPriority.ERROR, e) { "Failed to get extensions from $repoBaseUrl" }
emptyList()
}
}
@ -59,7 +99,8 @@ internal class ExtensionApi {
return null
}
updateExtensionStores()
// Update extension repo details
updateExtensionRepo.awaitAll()
val extensions = if (fromAvailableExtensionList) {
extensionManager.availableExtensionsFlow.value
@ -97,6 +138,47 @@ internal class ExtensionApi {
return extensionsWithUpdate
}
private fun List<ExtensionJsonObject>.toExtensions(
repoUrl: String,
// KMK -->
signature: String,
repoName: String,
// KMK <--
): List<Extension.Available> {
return this
.filter {
val libVersion = it.extractLibVersion()
libVersion >= ExtensionLoader.LIB_VERSION_MIN && libVersion <= ExtensionLoader.LIB_VERSION_MAX
}
.map {
Extension.Available(
name = it.name.substringAfter("Tachiyomi: "),
pkgName = it.pkg,
versionName = it.version,
versionCode = it.code,
libVersion = it.extractLibVersion(),
lang = it.lang,
isNsfw = it.nsfw == 1,
sources = it.sources?.map(extensionSourceMapper).orEmpty(),
apkName = it.apk,
iconUrl = "$repoUrl/icon/${it.pkg}.png",
repoUrl = repoUrl,
// KMK -->
signatureHash = signature,
repoName = repoName,
// KMK <--
)
}
}
fun getApkUrl(extension: Extension.Available): String {
return "${extension.repoUrl}/apk/${extension.apkName}"
}
private fun ExtensionJsonObject.extractLibVersion(): Double {
return version.substringBeforeLast('.').toDouble()
}
// SY -->
private fun Extension.isBlacklisted(
blacklistEnabled: Boolean = sourcePreferences.enableSourceBlacklist().get(),
@ -112,3 +194,32 @@ internal class ExtensionApi {
}
// SY <--
}
@Serializable
private data class ExtensionJsonObject(
val name: String,
val pkg: String,
val apk: String,
val lang: String,
val code: Long,
val version: String,
val nsfw: Int,
val sources: List<ExtensionSourceJsonObject>?,
)
@Serializable
private data class ExtensionSourceJsonObject(
val id: Long,
val lang: String,
val name: String,
val baseUrl: String,
)
private val extensionSourceMapper: (ExtensionSourceJsonObject) -> Extension.Available.Source = {
Extension.Available.Source(
id = it.id,
lang = it.lang,
name = it.name,
baseUrl = it.baseUrl,
)
}

View file

@ -24,9 +24,7 @@ abstract class Installer(private val service: Service) {
private val extensionManager: ExtensionManager by injectLazy()
private var waitingInstall = AtomicReference<Entry?>(null)
// KMK -->
private val queue = Collections.synchronizedSet(mutableSetOf<Entry>())
// KMK <--
private val queue = Collections.synchronizedList(mutableListOf<Entry>())
private val cancelReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
@ -106,9 +104,7 @@ abstract class Installer(private val service: Service) {
}
val nextEntry = queue.first()
if (waitingInstall.compareAndSet(null, nextEntry)) {
// KMK -->
queue.remove(nextEntry)
// KMK <--
queue.removeAt(0)
processEntry(nextEntry)
}
}
@ -133,11 +129,7 @@ abstract class Installer(private val service: Service) {
*/
private fun cancelQueue(downloadId: Long) {
val waitingInstall = this.waitingInstall.load()
// KMK -->
val toCancel = synchronized(queue) { queue.find { it.downloadId == downloadId } }
?: waitingInstall?.takeIf { it.downloadId == downloadId }
// KMK <--
?: return
val toCancel = queue.find { it.downloadId == downloadId } ?: waitingInstall ?: return
if (cancelEntry(toCancel)) {
queue.remove(toCancel)
if (waitingInstall == toCancel) {

View file

@ -1,6 +1,5 @@
package eu.kanade.tachiyomi.extension.installer
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.app.Service
import android.content.BroadcastReceiver
@ -15,7 +14,8 @@ import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.util.lang.use
import eu.kanade.tachiyomi.util.system.getParcelableExtraCompat
import eu.kanade.tachiyomi.util.system.getUriSize
import exh.log.xLogE
import logcat.LogPriority
import tachiyomi.core.common.util.system.logcat
class PackageInstallerInstaller(private val service: Service) : Installer(service) {
@ -41,7 +41,7 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic
.sanitizeByFiltering(this)
}
if (userAction == null) {
xLogE("Fatal error for $intent")
logcat(LogPriority.ERROR) { "Fatal error for $intent" }
continueQueue(InstallStep.Error)
return
}
@ -82,7 +82,6 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic
inputStream.copyTo(outputStream)
session.fsync(outputStream)
}
service.contentResolver.delete(entry.uri, null, null)
val intentSender = PendingIntent.getBroadcast(
service,
@ -90,11 +89,10 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic
Intent(INSTALL_ACTION).setPackage(service.packageName),
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0,
).intentSender
@SuppressLint("RequestInstallPackagesPolicy")
session.commit(intentSender)
}
} catch (e: Exception) {
xLogE("Failed to install extension ${entry.downloadId} ${entry.uri}", e)
logcat(LogPriority.ERROR, e) { "Failed to install extension ${entry.downloadId} ${entry.uri}" }
activeSession?.let { (_, sessionId) ->
packageInstaller.abandonSession(sessionId)
}
@ -105,13 +103,8 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic
override fun cancelEntry(entry: Entry): Boolean {
activeSession?.let { (activeEntry, sessionId) ->
if (activeEntry == entry) {
return try {
packageInstaller.abandonSession(sessionId)
false
} catch (_: SecurityException) {
// Highly likely the session has succeeded
true
}
packageInstaller.abandonSession(sessionId)
return false
}
}
return true

View file

@ -111,17 +111,20 @@ class ShizukuInstaller(private val service: Service) : Installer(service) {
override fun processEntry(entry: Entry) {
super.processEntry(entry)
// KMK -->
val installer = shellInterface
if (installer == null) {
logcat(LogPriority.ERROR) { "Shizuku shell interface not available for ${entry.downloadId}" }
continueQueue(InstallStep.Error)
return
}
try {
service.contentResolver.openAssetFileDescriptor(entry.uri, "r")?.use {
shellInterface?.install(it)
// KMK -->
?: throw Exception("Shell interface is not available")
// KMK <--
installer.install(it)
}
// KMK -->
?: throw Exception("Failed to open asset file descriptor")
// KMK <--
service.contentResolver.delete(entry.uri, null, null)
} catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Failed to install extension ${entry.downloadId} ${entry.uri}" }
continueQueue(InstallStep.Error)

View file

@ -2,7 +2,6 @@ package eu.kanade.tachiyomi.extension.model
import android.graphics.drawable.Drawable
import eu.kanade.tachiyomi.source.Source
import mihon.domain.extension.model.ExtensionStore
import tachiyomi.domain.source.model.StubSource
sealed class Extension {
@ -17,7 +16,7 @@ sealed class Extension {
// KMK -->
abstract val signatureHash: String
abstract val storeName: String?
abstract val repoName: String?
// KMK <--
data class Installed(
@ -30,8 +29,8 @@ sealed class Extension {
override val isNsfw: Boolean,
// KMK -->
override val signatureHash: String,
/** Guessing store name from built-in signatures preset */
override val storeName: String? = null,
/** Guessing repo name from built-in signatures preset */
override val repoName: String? = null,
// KMK <--
val pkgFactory: String?,
val sources: List<Source>,
@ -39,7 +38,7 @@ sealed class Extension {
val hasUpdate: Boolean = false,
val isObsolete: Boolean = false,
val isShared: Boolean,
val store: ExtensionStore? = null,
val repoUrl: String? = null,
// SY -->
val isRedundant: Boolean = false,
// SY <--
@ -55,12 +54,12 @@ sealed class Extension {
override val isNsfw: Boolean,
// KMK -->
override val signatureHash: String,
override val storeName: String,
override val repoName: String,
// KMK <--
val sources: List<Source>,
val apkUrl: String,
val apkName: String,
val iconUrl: String,
val store: ExtensionStore,
val repoUrl: String,
) : Extension() {
data class Source(
@ -88,7 +87,7 @@ sealed class Extension {
/* KMK --> */
override /* KMK <-- */ val signatureHash: String,
// KMK -->
override val storeName: String? = null,
override val repoName: String? = null,
// KMK <--
override val lang: String? = null,
override val isNsfw: Boolean = false,

View file

@ -64,11 +64,6 @@ class ExtensionInstallActivity : Activity() {
}
}
override fun onDestroy() {
super.onDestroy()
intent.data?.let { contentResolver.delete(it, null, null) }
}
private fun checkInstallationResult(resultCode: Int) {
val downloadId = intent.extras!!.getLong(ExtensionInstaller.EXTRA_DOWNLOAD_ID)
val extensionManager = Injekt.get<ExtensionManager>()

View file

@ -3,10 +3,8 @@ package eu.kanade.tachiyomi.extension.util
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Build
import android.os.IBinder
import androidx.core.content.ContextCompat
import eu.kanade.domain.base.BasePreferences
@ -18,15 +16,11 @@ import eu.kanade.tachiyomi.extension.installer.ShizukuInstaller
import eu.kanade.tachiyomi.extension.util.ExtensionInstaller.Companion.EXTRA_DOWNLOAD_ID
import eu.kanade.tachiyomi.util.system.getSerializableExtraCompat
import eu.kanade.tachiyomi.util.system.notificationBuilder
import exh.log.xLogE
import logcat.LogPriority
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.util.system.logcat
import tachiyomi.i18n.MR
/**
* Foreground service that stages and installs extension APKs via [PackageInstallerInstaller] or
* [ShizukuInstaller]. Uses [ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC] because installs can
* wait on multiple system confirmation dialogs; `shortService` times out after ~3 minutes.
*/
class ExtensionInstallService : Service() {
private var installer: Installer? = null
@ -42,17 +36,7 @@ class ExtensionInstallService : Service() {
setContentTitle(stringResource(MR.strings.ext_install_service_notif))
setProgress(100, 100, true)
}.build()
// KMK -->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
Notifications.ID_EXTENSION_INSTALLER,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
)
} else {
// KMK <--
startForeground(Notifications.ID_EXTENSION_INSTALLER, notification)
}
startForeground(Notifications.ID_EXTENSION_INSTALLER, notification)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@ -69,7 +53,7 @@ class ExtensionInstallService : Service() {
BasePreferences.ExtensionInstaller.PACKAGEINSTALLER -> PackageInstallerInstaller(this)
BasePreferences.ExtensionInstaller.SHIZUKU -> ShizukuInstaller(this)
else -> {
xLogE("Not implemented for installer $installerUsed")
logcat(LogPriority.ERROR) { "Not implemented for installer $installerUsed" }
stopSelf()
return START_NOT_STICKY
}

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