Compare commits
96 commits
v1.13.6-fo
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
437b7ad45d | ||
|
|
d342a8d595 | ||
|
|
d69ce1c388 | ||
|
|
49a0644f68 | ||
|
|
a81d717ca3 | ||
|
|
4fce1fa940 | ||
|
|
87a173929b | ||
|
|
4ae732a17b | ||
|
|
eb84ab8439 | ||
|
|
5e809fefd4 | ||
|
|
1763d947df | ||
|
|
ce6a0ea7fa | ||
|
|
da57b3db07 | ||
|
|
da7ee11bb3 | ||
|
|
21c1b85796 | ||
|
|
0f556ea9b5 | ||
|
|
06107dba9b | ||
|
|
3615dc23a9 | ||
|
|
86b60f08b1 | ||
|
|
16582cc50e | ||
|
|
559a787d0f | ||
|
|
65d2bff8c7 | ||
|
|
3e07479b0e | ||
|
|
40b19479af | ||
|
|
4fd2625bf7 | ||
|
|
453afb3edb | ||
|
|
118eeaf3ee | ||
|
|
e11cd536dc | ||
|
|
e95e463543 | ||
|
|
7b073fed0c | ||
|
|
5c84266bc7 | ||
|
|
5048e57e76 | ||
|
|
583d649d20 | ||
|
|
b299a9ce81 | ||
|
|
fdfd4582cb | ||
|
|
25b8eb889a | ||
|
|
1ffeebc9a1 | ||
|
|
3de5da42d3 | ||
|
|
e9bd1fc477 | ||
|
|
72a3bb6f58 | ||
|
|
0ba6598c3f | ||
|
|
bd13620d1f | ||
|
|
40f1391775 | ||
|
|
9761f33948 | ||
|
|
612651a076 | ||
|
|
3b06366fd9 | ||
|
|
582ea3e60d | ||
|
|
99470e8133 | ||
|
|
9ccf5ca291 | ||
|
|
30c114c20f | ||
|
|
c1806e13b3 | ||
|
|
266947b0d7 | ||
|
|
ca26501aef | ||
|
|
9a7d1b6143 | ||
|
|
94eac94ce7 | ||
|
|
dc7dd8045e | ||
|
|
0b285f83b1 | ||
|
|
768c3dcec5 | ||
|
|
f178a79195 | ||
|
|
19f8fa203d | ||
|
|
21cb1921a3 | ||
|
|
dc3d034109 | ||
|
|
0293507d1a | ||
|
|
3344fd0755 | ||
|
|
f5e1c1e5ce | ||
|
|
5c0ca305a9 | ||
|
|
1f904815fa | ||
|
|
38acd6f110 | ||
|
|
bad1dd264d | ||
|
|
6124efa164 | ||
|
|
0830cd32b5 | ||
|
|
2b4caa9f97 | ||
|
|
0f815ba662 | ||
|
|
1c9c35156d | ||
|
|
7c5fe16b52 | ||
|
|
e04565bc46 | ||
|
|
e0f4262407 | ||
|
|
6316b85a09 | ||
|
|
f91af6fc72 | ||
|
|
c15d280cb4 | ||
|
|
07019175d1 | ||
|
|
9ce3f0ba29 | ||
|
|
7fab10c8ef | ||
|
|
e89f342881 | ||
|
|
7bca00964e | ||
|
|
06bc3a6a0c | ||
|
|
940a669936 | ||
|
|
b81d3eb198 | ||
|
|
fe43d3ef2c | ||
|
|
384aee7372 | ||
|
|
de5e0a32d3 | ||
|
|
e50767709a | ||
|
|
c290472012 | ||
|
|
aa7198f082 | ||
|
|
a52609b549 | ||
|
|
4c21f019c0 |
255 changed files with 6781 additions and 3271 deletions
|
|
@ -5,6 +5,12 @@ 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,6 +27,7 @@ jobs:
|
|||
shell: bash
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
DISPATCH_TAG: ${{ github.event.inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf .git
|
||||
|
|
@ -28,7 +35,11 @@ jobs:
|
|||
origin_url="${GITHUB_SERVER_URL#https://}"
|
||||
origin_url="${origin_url#http://}"
|
||||
git remote add origin "https://copilot:${FORGEJO_TOKEN}@${origin_url}/${GITHUB_REPOSITORY}.git"
|
||||
git -c http.sslVerify=false fetch --tags --prune --depth=1 origin "${GITHUB_SHA}"
|
||||
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
|
||||
|
||||
- name: Set up JDK
|
||||
|
|
@ -71,31 +82,169 @@ jobs:
|
|||
echo "JAVA_HOME=$java_home" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- 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: Prepare release metadata
|
||||
id: prepare
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
version_tag="${GITHUB_REF_NAME:-${GITHUB_REF#refs/tags/}}"
|
||||
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//' )"
|
||||
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 "$previous_tag" ]; then
|
||||
commit_logs="$(git log --pretty=format:'- %s (@%an)' "${previous_tag}..HEAD")"
|
||||
else
|
||||
commit_logs='- Initial release'
|
||||
if [ -n "$upstream_base" ]; then
|
||||
git fetch --no-tags --depth=1 "https://github.com/${upstream_repo}.git" "$upstream_base" || true
|
||||
fi
|
||||
|
||||
{
|
||||
echo 'COMMIT_LOGS<<EOF'
|
||||
printf '%s\n' "$commit_logs"
|
||||
echo 'EOF'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
# 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}"
|
||||
else
|
||||
printf '%s\n' "##### Other" "" "- First release tag, or no prior \`v*\` tag found — see repository history." "" > "${commit_log_file}"
|
||||
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}"
|
||||
|
||||
- name: Set up Gradle
|
||||
shell: bash
|
||||
|
|
@ -129,10 +278,15 @@ jobs:
|
|||
echo "ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT" >> "$GITHUB_ENV"
|
||||
sdkmanager_bin="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager"
|
||||
if [ -x "$sdkmanager_bin" ]; then
|
||||
set +o pipefail
|
||||
yes | "$sdkmanager_bin" --licenses >/dev/null || true
|
||||
yes | "$sdkmanager_bin" "platform-tools" "build-tools;35.0.0"
|
||||
set -o pipefail
|
||||
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
|
||||
|
|
@ -143,15 +297,15 @@ jobs:
|
|||
fi
|
||||
printf 'sdk.dir=%s\n' "$ANDROID_HOME" > local.properties
|
||||
chmod +x ./gradlew
|
||||
export GRADLE_USER_HOME="${PWD}/.gradle-ci"
|
||||
./gradlew --no-daemon --max-workers=1 \
|
||||
"-Dorg.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=640m -XX:ReservedCodeCacheSize=192m -Dfile.encoding=UTF-8" \
|
||||
"-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=-Xmx512m,-XX:MaxMetaspaceSize=256m,-XX:ReservedCodeCacheSize=128m" \
|
||||
"-Dkotlin.daemon.jvmargs=-Xmx2048m,-XX:MaxMetaspaceSize=512m,-XX:ReservedCodeCacheSize=256m" \
|
||||
-Dkotlin.compiler.execution.strategy=in-process \
|
||||
-Pkotlin.incremental=true \
|
||||
--version
|
||||
|
||||
- name: Build app
|
||||
|
|
@ -159,15 +313,15 @@ jobs:
|
|||
if [ -n "${ANDROID_HOME:-}" ]; then
|
||||
printf 'sdk.dir=%s\n' "$ANDROID_HOME" > local.properties
|
||||
fi
|
||||
export GRADLE_USER_HOME="${PWD}/.gradle-ci"
|
||||
./gradlew --no-daemon --max-workers=1 \
|
||||
"-Dorg.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=640m -XX:ReservedCodeCacheSize=192m -Dfile.encoding=UTF-8" \
|
||||
"-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=-Xmx512m,-XX:MaxMetaspaceSize=256m,-XX:ReservedCodeCacheSize=128m" \
|
||||
"-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
|
||||
|
||||
|
|
@ -232,6 +386,33 @@ jobs:
|
|||
"${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: |
|
||||
|
|
@ -267,12 +448,12 @@ jobs:
|
|||
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:-}"
|
||||
|
||||
|
|
@ -352,10 +533,13 @@ jobs:
|
|||
payload_file="$(mktemp)"
|
||||
response_file="$(mktemp)"
|
||||
|
||||
commit_logs="${COMMIT_LOGS:-}"
|
||||
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="- Initial release"
|
||||
commit_logs=$'##### Other\n\n- Initial release\n'
|
||||
fi
|
||||
|
||||
full_changelog=""
|
||||
|
|
@ -363,60 +547,89 @@ jobs:
|
|||
full_changelog="**Full Changelog**: [${REPOSITORY_NAME}@${PREV_TAG_NAME}...${VERSION_TAG}](${REPO_URL}/compare/${PREV_TAG_NAME}...${VERSION_TAG})"
|
||||
fi
|
||||
|
||||
release_body="$(printf '%s\n' \
|
||||
"#### What's Changed" \
|
||||
"" \
|
||||
"##### New" \
|
||||
"" \
|
||||
"##### Improve" \
|
||||
"" \
|
||||
"##### Fix" \
|
||||
"" \
|
||||
"${commit_logs}" \
|
||||
"" \
|
||||
"##### Based on" \
|
||||
"" \
|
||||
"${full_changelog}" \
|
||||
"" \
|
||||
"<!-->" \
|
||||
"> [!TIP]" \
|
||||
">" \
|
||||
"> ### If you are unsure which version to download then go with \`Komikku-universal.apk\`")"
|
||||
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}"
|
||||
|
||||
# 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}" \
|
||||
--arg body "${release_body}" \
|
||||
'{tag_name: $tag, name: $title, body: $body, draft: true, prerelease: false}' > "${payload_file}"
|
||||
--rawfile body "${release_body_file}" \
|
||||
'{tag_name: $tag, name: $title, body: $body, draft: false, prerelease: false}' > "${payload_file}"
|
||||
|
||||
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}"
|
||||
|
||||
status="$(curl "${curl_tls_args[@]}" -sS -o "$response_file" -w '%{http_code}' \
|
||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||
"${api_base}/tags/${VERSION_TAG}")"
|
||||
"${tag_get_url}")"
|
||||
|
||||
if [ "$status" = "200" ]; then
|
||||
release_id="$("${JQ_BIN}" -r '.id' < "$response_file")"
|
||||
curl "${curl_tls_args[@]}" -fsS \
|
||||
-X PATCH \
|
||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data @"$payload_file" \
|
||||
"${api_base}/${release_id}" \
|
||||
> "$response_file"
|
||||
elif [ "$status" = "404" ]; then
|
||||
curl "${curl_tls_args[@]}" -fsS \
|
||||
-X POST \
|
||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data @"$payload_file" \
|
||||
"${api_base}" \
|
||||
> "$response_file"
|
||||
release_id="$("${JQ_BIN}" -r '.id' < "$response_file")"
|
||||
else
|
||||
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
|
||||
|
||||
echo "RELEASE_ID=$release_id" >> "$GITHUB_OUTPUT"
|
||||
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}' \
|
||||
-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}' \
|
||||
-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
|
||||
fi
|
||||
|
||||
echo "RELEASE_ID=${release_id}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload release assets
|
||||
shell: bash
|
||||
|
|
@ -501,13 +714,24 @@ jobs:
|
|||
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)"
|
||||
|
||||
curl "${curl_tls_args[@]}" -fsS \
|
||||
assets_status="$(curl "${curl_tls_args[@]}" -sS -o "$assets_file" -w '%{http_code}' \
|
||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||
"${release_api}/assets" \
|
||||
> "$assets_file"
|
||||
"${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
|
||||
|
||||
for file in Komikku-*.apk Komikku-mapping-*.zip; do
|
||||
[ -e "$file" ] || continue
|
||||
|
|
@ -520,20 +744,32 @@ jobs:
|
|||
asset_id="$("${JQ_BIN}" -r --arg n "${asset_name}" '(.[] | select(.name == $n) | .id) // empty' "$assets_file")"
|
||||
|
||||
if [ -n "$asset_id" ] && [ "$asset_id" != "null" ]; then
|
||||
curl "${curl_tls_args[@]}" -fsS \
|
||||
del_status="$(curl "${curl_tls_args[@]}" -sS -o "$err_body" -w '%{http_code}' \
|
||||
-X DELETE \
|
||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||
"${release_api}/assets/${asset_id}" \
|
||||
> /dev/null
|
||||
"${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
|
||||
fi
|
||||
|
||||
encoded_name="$("${JQ_BIN}" -nr --arg s "${asset_name}" '$s | @uri')"
|
||||
|
||||
curl "${curl_tls_args[@]}" -fsS \
|
||||
up_status="$(curl "${curl_tls_args[@]}" -sS -o "$err_body" -w '%{http_code}' \
|
||||
-X POST \
|
||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||
-H 'Content-Type: application/octet-stream' \
|
||||
--data-binary @"$file" \
|
||||
"${release_api}/assets?name=${encoded_name}" \
|
||||
> /dev/null
|
||||
"${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})"
|
||||
done
|
||||
rm -f "$assets_file" "$err_body"
|
||||
|
|
|
|||
100
.forgejo/workflows/upstream_status.yml
Normal file
100
.forgejo/workflows/upstream_status.yml
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
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"
|
||||
1
.github/.java-version
vendored
Normal file
1
.github/.java-version
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
21
|
||||
12
.github/workflows/build_benchmark.yml
vendored
Executable file → Normal file
12
.github/workflows/build_benchmark.yml
vendored
Executable file → Normal 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@v4 # Forgejo-compatible (node20)
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
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@v4 # Forgejo-compatible (node20)
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@v4 # Forgejo-compatible (node20)
|
||||
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
|
||||
with:
|
||||
java-version: 17
|
||||
java-version-file: .github/.java-version
|
||||
distribution: temurin
|
||||
|
||||
- name: Set up gradle
|
||||
uses: gradle/actions/setup-gradle@v4.4.2 # Forgejo-compatible (node20)
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6
|
||||
|
||||
- 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@v4 # Forgejo-compatible (node20)
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
path: "**/*.apk"
|
||||
retention-days: 30
|
||||
|
|
|
|||
4
.github/workflows/build_dispatch_preview.yml
vendored
Executable file → Normal file
4
.github/workflows/build_dispatch_preview.yml
vendored
Executable file → Normal file
|
|
@ -30,7 +30,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Clone repo
|
||||
uses: actions/checkout@v4 # Forgejo-compatible (node20)
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
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@v1.2.2 # Forgejo-compatible (node16)
|
||||
uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
|
||||
with:
|
||||
workflow: build_app.yml
|
||||
repo: komikku-app/komikku-preview
|
||||
|
|
|
|||
16
.github/workflows/build_preview.yml
vendored
Executable file → Normal file
16
.github/workflows/build_preview.yml
vendored
Executable file → Normal 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@v4 # Forgejo-compatible (node20)
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
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@v4 # Forgejo-compatible (node20)
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@v4 # Forgejo-compatible (node20)
|
||||
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
|
||||
with:
|
||||
java-version: 17
|
||||
java-version-file: .github/.java-version
|
||||
distribution: temurin
|
||||
|
||||
- name: Write google-services.json
|
||||
|
|
@ -144,7 +144,7 @@ jobs:
|
|||
write-mode: overwrite
|
||||
|
||||
- name: Set up gradle
|
||||
uses: gradle/actions/setup-gradle@v4.4.2 # Forgejo-compatible (node20)
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6
|
||||
|
||||
- name: Check code format
|
||||
run: ./gradlew spotlessCheck
|
||||
|
|
@ -156,7 +156,7 @@ jobs:
|
|||
run: ./gradlew testReleaseUnitTest
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4 # Forgejo-compatible (node20)
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
path: "**/*.apk"
|
||||
retention-days: 1
|
||||
|
|
@ -168,7 +168,7 @@ jobs:
|
|||
- build-app
|
||||
steps:
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4 # Forgejo-compatible (node20)
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
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@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
|
||||
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
|
||||
with:
|
||||
tag_name: ${{ needs.prepare-build.outputs.TAG_NAME }}
|
||||
name: Komikku Preview ${{ needs.prepare-build.outputs.TAG_NAME }} (${{ needs.prepare-build.outputs.VERSION_TAG }})
|
||||
|
|
|
|||
18
.github/workflows/build_pull_request.yml
vendored
Executable file → Normal file
18
.github/workflows/build_pull_request.yml
vendored
Executable file → Normal file
|
|
@ -30,20 +30,20 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Clone repo
|
||||
uses: actions/checkout@v4 # Forgejo-compatible (node20)
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Dependency Review
|
||||
uses: actions/dependency-review-action@05fe4576374b728f0c523d6a13d64c25081e0803 # v4.8.3
|
||||
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
|
||||
|
||||
- name: Validate Gradle Wrapper
|
||||
uses: gradle/actions/wrapper-validation@v4.4.2 # Forgejo-compatible (node20)
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@v4 # Forgejo-compatible (node20)
|
||||
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
|
||||
with:
|
||||
java-version: 17
|
||||
java-version-file: .github/.java-version
|
||||
distribution: temurin
|
||||
|
||||
- name: Write google-services.json
|
||||
|
|
@ -63,7 +63,7 @@ jobs:
|
|||
write-mode: overwrite
|
||||
|
||||
- name: Set up gradle
|
||||
uses: gradle/actions/setup-gradle@v4.4.2 # Forgejo-compatible (node20)
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6
|
||||
|
||||
- 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@v4 # Forgejo-compatible (node20)
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
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@v4 # Forgejo-compatible (node20)
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
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@v4 # Forgejo-compatible (node20)
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: mapping-${{ github.sha }}
|
||||
path: app/build/outputs/mapping/preview
|
||||
|
|
|
|||
14
.github/workflows/build_push.yml
vendored
Executable file → Normal file
14
.github/workflows/build_push.yml
vendored
Executable file → Normal file
|
|
@ -27,14 +27,14 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Clone repo
|
||||
uses: actions/checkout@v4 # Forgejo-compatible (node20)
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@v4 # Forgejo-compatible (node20)
|
||||
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
|
||||
with:
|
||||
java-version: 17
|
||||
java-version-file: .github/.java-version
|
||||
distribution: temurin
|
||||
|
||||
- name: Write google-services.json
|
||||
|
|
@ -52,7 +52,7 @@ jobs:
|
|||
write-mode: overwrite
|
||||
|
||||
- name: Set up gradle
|
||||
uses: gradle/actions/setup-gradle@v4.4.2 # Forgejo-compatible (node20)
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6
|
||||
|
||||
- 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@v4 # Forgejo-compatible (node20)
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
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@v4 # Forgejo-compatible (node20)
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
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@v4 # Forgejo-compatible (node20)
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: mapping-${{ github.sha }}
|
||||
path: app/build/outputs/mapping/preview
|
||||
|
|
|
|||
18
.github/workflows/build_release.yml
vendored
Executable file → Normal file
18
.github/workflows/build_release.yml
vendored
Executable file → Normal 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@v4 # Forgejo-compatible (node20)
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
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@v4 # Forgejo-compatible (node20)
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@v4 # Forgejo-compatible (node20)
|
||||
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
|
||||
with:
|
||||
java-version: 17
|
||||
java-version-file: .github/.java-version
|
||||
distribution: temurin
|
||||
|
||||
- name: Write google-services.json
|
||||
|
|
@ -112,7 +112,7 @@ jobs:
|
|||
write-mode: overwrite
|
||||
|
||||
- name: Set up gradle
|
||||
uses: gradle/actions/setup-gradle@v4.4.2 # Forgejo-compatible (node20)
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6
|
||||
|
||||
- name: Check code format
|
||||
run: ./gradlew spotlessCheck
|
||||
|
|
@ -124,13 +124,13 @@ jobs:
|
|||
run: ./gradlew testReleaseUnitTest
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4 # Forgejo-compatible (node20)
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
path: "**/*.apk"
|
||||
retention-days: 1
|
||||
|
||||
- name: Upload mapping
|
||||
uses: actions/upload-artifact@v4 # Forgejo-compatible (node20)
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
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@v4 # Forgejo-compatible (node20)
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
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@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
|
||||
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
|
||||
with:
|
||||
tag_name: ${{ needs.prepare-build.outputs.VERSION_TAG }}
|
||||
name: Komikku ${{ needs.prepare-build.outputs.VERSION_TAG }}
|
||||
|
|
|
|||
2
.github/workflows/codeberg_mirror.yml
vendored
Executable file → Normal file
2
.github/workflows/codeberg_mirror.yml
vendored
Executable file → Normal file
|
|
@ -13,7 +13,7 @@ jobs:
|
|||
if: github.repository == 'komikku-app/komikku'
|
||||
runs-on: 'ubuntu-24.04'
|
||||
steps:
|
||||
- uses: actions/checkout@v4 # Forgejo-compatible (node20)
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup SSH key
|
||||
|
|
|
|||
2
.github/workflows/delete_merged_branch.yml
vendored
Executable file → Normal file
2
.github/workflows/delete_merged_branch.yml
vendored
Executable file → Normal 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@v4 # Forgejo-compatible (node20)
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
|
||||
- name: Delete branch
|
||||
run: |
|
||||
|
|
|
|||
2
.github/workflows/pr_label.yml
vendored
Executable file → Normal file
2
.github/workflows/pr_label.yml
vendored
Executable file → Normal file
|
|
@ -15,7 +15,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Check PR and Add Label
|
||||
uses: actions/github-script@v7 # Forgejo-compatible (node20)
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
script: |
|
||||
const prAuthor = context.payload.pull_request.user.login;
|
||||
|
|
|
|||
2
.github/workflows/update_website.yml
vendored
Executable file → Normal file
2
.github/workflows/update_website.yml
vendored
Executable file → Normal file
|
|
@ -13,7 +13,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Update website on release
|
||||
uses: benc-uk/workflow-dispatch@v1.2.2 # Forgejo-compatible (node16)
|
||||
uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
|
||||
with:
|
||||
workflow: Deploy
|
||||
repo: komikku-app/komikku-app.github.io
|
||||
|
|
|
|||
218
AGENTS.md
Normal file
218
AGENTS.md
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
# 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`.
|
||||
55
CHANGELOG.md
55
CHANGELOG.md
|
|
@ -12,9 +12,54 @@ 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))
|
||||
|
||||
|
|
@ -22,10 +67,16 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
|
|||
- 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))
|
||||
|
|
@ -34,6 +85,10 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
|
|||
- 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))
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@
|
|||
|
||||
*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).
|
||||
|
||||
[](https://github.com/sponsors/cuong-tran "Sponsor me on GitHub")
|
||||
|
||||
<div align="left">
|
||||
|
|
@ -59,7 +63,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.
|
||||
- `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).
|
||||
- Auto-install app update.
|
||||
- Configurable interval to refresh entries from downloaded storage.
|
||||
- Forked from SY so everything from SY.
|
||||
|
|
@ -122,7 +126,6 @@ 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
|
||||
|
|
|
|||
48
UPSTREAM_STATUS.md
Normal file
48
UPSTREAM_STATUS.md
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# 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
|
||||
```
|
||||
|
|
@ -28,7 +28,7 @@ android {
|
|||
defaultConfig {
|
||||
applicationId = "app.komikku"
|
||||
|
||||
versionCode = 78
|
||||
versionCode = 79
|
||||
versionName = "1.13.6"
|
||||
|
||||
buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"")
|
||||
|
|
|
|||
2
app/proguard-rules.pro
vendored
2
app/proguard-rules.pro
vendored
|
|
@ -307,3 +307,5 @@
|
|||
-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
|
||||
|
|
|
|||
|
|
@ -73,8 +73,8 @@
|
|||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Deep link to add repos -->
|
||||
<intent-filter android:label="@string/action_add_repo">
|
||||
<!-- Legacy 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" />
|
||||
|
|
@ -84,6 +84,17 @@
|
|||
<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" />
|
||||
|
|
@ -241,7 +252,7 @@
|
|||
<service
|
||||
android:name=".extension.util.ExtensionInstallService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="shortService" />
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<service
|
||||
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
|
||||
|
|
@ -333,22 +344,6 @@
|
|||
|
||||
<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" />
|
||||
|
|
@ -363,21 +358,6 @@
|
|||
|
||||
<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" />
|
||||
|
|
|
|||
|
|
@ -16701,9 +16701,7 @@ 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
|
||||
|
|
|
|||
|
|
@ -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.repository.ExtensionRepoRepositoryImpl
|
||||
import mihon.data.extension.repository.ExtensionStoreRepositoryImpl
|
||||
import mihon.data.extension.service.ExtensionStoreService
|
||||
import mihon.domain.chapter.interactor.FilterChaptersForDownload
|
||||
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.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.migration.usecases.MigrateMangaUseCase
|
||||
import mihon.domain.upcoming.interactor.GetUpcomingManga
|
||||
import tachiyomi.data.category.CategoryRepositoryImpl
|
||||
|
|
@ -58,6 +58,7 @@ 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
|
||||
|
|
@ -65,7 +66,9 @@ 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
|
||||
|
|
@ -162,6 +165,7 @@ 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()) }
|
||||
|
|
@ -169,13 +173,16 @@ 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()) }
|
||||
|
||||
|
|
@ -199,14 +206,14 @@ class DomainModule : InjektModule {
|
|||
addFactory { ToggleSourcePin(get()) }
|
||||
addFactory { TrustExtension(get(), 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()) }
|
||||
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()) }
|
||||
|
||||
addFactory { ToggleIncognito(get()) }
|
||||
addFactory { GetIncognitoState(get(), get(), get()) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,11 +28,7 @@ class GetExtensionLanguages(
|
|||
) { enabledLanguage, availableExtensions ->
|
||||
availableExtensions
|
||||
.flatMap { ext ->
|
||||
if (ext.sources.isEmpty()) {
|
||||
listOf(ext.lang)
|
||||
} else {
|
||||
ext.sources.map { it.lang }
|
||||
}
|
||||
ext.sources.map { it.lang }
|
||||
}
|
||||
.distinct()
|
||||
.sortedWith(
|
||||
|
|
|
|||
|
|
@ -50,9 +50,6 @@ 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(
|
||||
|
|
|
|||
|
|
@ -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.extensionrepo.interactor.CreateExtensionRepo
|
||||
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
|
||||
import mihon.domain.extension.model.KOMIKKU_SIGNATURE
|
||||
import mihon.domain.extension.repository.ExtensionStoreRepository
|
||||
import tachiyomi.core.common.preference.getAndSet
|
||||
|
||||
class TrustExtension(
|
||||
private val extensionRepoRepository: ExtensionRepoRepository,
|
||||
private val repository: ExtensionStoreRepository,
|
||||
private val preferences: SourcePreferences,
|
||||
) {
|
||||
|
||||
suspend fun isTrusted(pkgInfo: PackageInfo, fingerprints: List<String>): Boolean {
|
||||
// KMK -->
|
||||
if (isDebugBuildType) return true
|
||||
if (fingerprints.contains(CreateExtensionRepo.KOMIKKU_SIGNATURE)) return true
|
||||
if (fingerprints.contains(KOMIKKU_SIGNATURE)) return true
|
||||
// KMK <--
|
||||
val trustedFingerprints = extensionRepoRepository.getAll().map { it.signingKeyFingerprint }.toHashSet()
|
||||
val trustedFingerprints = repository.getAll().map { it.signingKey }.toHashSet()
|
||||
val key = "${pkgInfo.packageName}:${PackageInfoCompat.getLongVersionCode(pkgInfo)}:${fingerprints.last()}"
|
||||
return trustedFingerprints.any { fingerprints.contains(it) } || key in preferences.trustedExtensions().get()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ import java.util.UUID
|
|||
class SyncPreferences(
|
||||
private val preferenceStore: PreferenceStore,
|
||||
) {
|
||||
fun clientHost() = preferenceStore.getString("sync_client_host", "https://sync.tachiyomi.org")
|
||||
fun clientAPIKey() = preferenceStore.getString("sync_client_api_key", "")
|
||||
fun clientHost() = preferenceStore.getString("connection_sync_client_host", "https://sync.tachiyomi.org")
|
||||
fun clientAPIKey() = preferenceStore.getString("connection_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("webdav_url", "")
|
||||
fun webDavUsername() = preferenceStore.getString("webdav_username", "")
|
||||
fun webDavPassword() = preferenceStore.getString("webdav_password", "")
|
||||
fun webDavFolder() = preferenceStore.getString("webdav_folder", "komikku")
|
||||
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")
|
||||
// KMK <--
|
||||
|
||||
fun googleDriveAccessToken() = preferenceStore.getString(
|
||||
Preference.appStateKey("google_drive_access_token"),
|
||||
Preference.appStateKey("connection_google_drive_access_token"),
|
||||
"",
|
||||
)
|
||||
|
||||
fun googleDriveRefreshToken() = preferenceStore.getString(
|
||||
Preference.appStateKey("google_drive_refresh_token"),
|
||||
Preference.appStateKey("connection_google_drive_refresh_token"),
|
||||
"",
|
||||
)
|
||||
|
||||
|
|
@ -55,39 +55,39 @@ class SyncPreferences(
|
|||
|
||||
fun getSyncSettings(): SyncSettings {
|
||||
return SyncSettings(
|
||||
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(),
|
||||
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(),
|
||||
|
||||
// SY -->
|
||||
customInfo = preferenceStore.getBoolean("customInfo", true).get(),
|
||||
readEntries = preferenceStore.getBoolean("readEntries", true).get(),
|
||||
savedSearchesFeeds = preferenceStore.getBoolean("savedSearchesFeeds", true).get(),
|
||||
customInfo = preferenceStore.getBoolean("sync_customInfo", true).get(),
|
||||
readEntries = preferenceStore.getBoolean("sync_readEntries", true).get(),
|
||||
savedSearchesFeeds = preferenceStore.getBoolean("sync_savedSearchesFeeds", true).get(),
|
||||
// SY <--
|
||||
)
|
||||
}
|
||||
|
||||
fun setSyncSettings(syncSettings: SyncSettings) {
|
||||
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)
|
||||
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)
|
||||
|
||||
// SY -->
|
||||
preferenceStore.getBoolean("customInfo", true).set(syncSettings.customInfo)
|
||||
preferenceStore.getBoolean("readEntries", true).set(syncSettings.readEntries)
|
||||
preferenceStore.getBoolean("savedSearchesFeeds", true).set(syncSettings.savedSearchesFeeds)
|
||||
preferenceStore.getBoolean("sync_customInfo", true).set(syncSettings.customInfo)
|
||||
preferenceStore.getBoolean("sync_readEntries", true).set(syncSettings.readEntries)
|
||||
preferenceStore.getBoolean("sync_savedSearchesFeeds", true).set(syncSettings.savedSearchesFeeds)
|
||||
// SY <--
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ data class SyncSettings(
|
|||
val tracking: Boolean = true,
|
||||
val history: Boolean = true,
|
||||
val appSettings: Boolean = true,
|
||||
val extensionRepoSettings: Boolean = true,
|
||||
val extensionStores: Boolean = true,
|
||||
val sourceSettings: Boolean = true,
|
||||
val privateSettings: Boolean = false,
|
||||
|
||||
|
|
|
|||
|
|
@ -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?.repoUrl.orEmpty())
|
||||
regex.find(state.extension?.store?.indexUrl.orEmpty())
|
||||
?.let {
|
||||
val (user, repo) = it.destructured
|
||||
"https://github.com/$user/$repo"
|
||||
}
|
||||
?: state.extension?.repoUrl
|
||||
?: state.extension?.store?.indexUrl
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
|
|
@ -270,14 +270,17 @@ private fun DetailsHeader(
|
|||
|
||||
if (extension is Extension.Installed) {
|
||||
append("\n\n")
|
||||
append(
|
||||
appendLine(
|
||||
"""
|
||||
Update available: ${extension.hasUpdate}
|
||||
Obsolete: ${extension.isObsolete}
|
||||
Orphaned: ${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)
|
||||
|
|
|
|||
|
|
@ -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.ExtensionReposScreen
|
||||
import eu.kanade.presentation.more.settings.screen.browse.ExtensionStoresScreen
|
||||
import eu.kanade.presentation.util.animateItemFastScroll
|
||||
import eu.kanade.presentation.util.rememberRequestPackageInstallsPermissionState
|
||||
import eu.kanade.tachiyomi.extension.model.Extension
|
||||
|
|
@ -58,6 +58,8 @@ 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
|
||||
|
|
@ -110,9 +112,9 @@ fun ExtensionScreen(
|
|||
modifier = Modifier.padding(contentPadding),
|
||||
actions = persistentListOf(
|
||||
EmptyScreenAction(
|
||||
stringRes = MR.strings.label_extension_repos,
|
||||
stringRes = MR.strings.extensionStores,
|
||||
icon = Icons.Outlined.Settings,
|
||||
onClick = { navigator.push(ExtensionReposScreen()) },
|
||||
onClick = { navigator.push(ExtensionStoresScreen()) },
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
@ -195,9 +197,9 @@ private fun ExtensionContent(
|
|||
// KMK -->
|
||||
KMR.strings.extensions_page_more -> {
|
||||
{
|
||||
Button(onClick = { navigator?.push(ExtensionReposScreen()) }) {
|
||||
Button(onClick = { navigator?.push(ExtensionStoresScreen()) }) {
|
||||
Text(
|
||||
text = stringResource(MR.strings.action_add_repo),
|
||||
text = stringResource(MR.strings.action_addExtensionStore),
|
||||
style = LocalTextStyle.current.copy(
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
),
|
||||
|
|
@ -401,7 +403,7 @@ private fun ExtensionItemContent(
|
|||
}
|
||||
|
||||
// KMK -->
|
||||
Text(text = extension.repoName?.let { "@$it" } ?: "(?)")
|
||||
Text(text = extension.storeName?.let { "@$it" } ?: "(?)")
|
||||
// KMK <--
|
||||
|
||||
val warning = when {
|
||||
|
|
@ -604,11 +606,11 @@ private fun ExtensionItemContentPreview() {
|
|||
libVersion = 1.0,
|
||||
isNsfw = true,
|
||||
signatureHash = "900000",
|
||||
repoName = "Repository",
|
||||
storeName = "Repository",
|
||||
sources = emptyList(),
|
||||
apkName = "Test",
|
||||
apkUrl = "Test",
|
||||
iconUrl = "",
|
||||
repoUrl = "",
|
||||
store = ExtensionStore("https://repo", "Komikku", "", KOMIKKU_SIGNATURE, ExtensionStore.Contact("", ""), false),
|
||||
)
|
||||
val extInstalled = Extension.Installed(
|
||||
name = "Tachiyomi",
|
||||
|
|
@ -619,9 +621,9 @@ private fun ExtensionItemContentPreview() {
|
|||
libVersion = 1.0,
|
||||
isNsfw = true,
|
||||
signatureHash = "900000",
|
||||
repoName = "Repository",
|
||||
storeName = "Repository",
|
||||
sources = emptyList(),
|
||||
repoUrl = "",
|
||||
store = ExtensionStore("https://repo", "Komikku", "", KOMIKKU_SIGNATURE, ExtensionStore.Contact("", ""), false),
|
||||
pkgFactory = null,
|
||||
icon = null,
|
||||
hasUpdate = false,
|
||||
|
|
@ -638,12 +640,12 @@ private fun ExtensionItemContentPreview() {
|
|||
libVersion = 1.0,
|
||||
isNsfw = true,
|
||||
signatureHash = "900000",
|
||||
repoName = "Repository",
|
||||
storeName = "Repository",
|
||||
)
|
||||
Column {
|
||||
ExtensionItemContent(
|
||||
extension = extAvail.copy(
|
||||
repoName = "Repository extensions minion multiple languages various sources",
|
||||
storeName = "Repository extensions minion multiple languages various sources",
|
||||
),
|
||||
installStep = InstallStep.Idle,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -336,47 +336,46 @@ 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 {
|
||||
add(
|
||||
AppBar.Action(
|
||||
title = stringResource(MR.strings.action_web_view),
|
||||
onClick = { onWebViewClick() },
|
||||
icon = Icons.Outlined.Public,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// KMK -->
|
||||
onWebViewClick?.let { func ->
|
||||
add(
|
||||
AppBar.OverflowAction(
|
||||
title = stringResource(MR.strings.pref_incognito_mode),
|
||||
onClick = onToggleIncognito,
|
||||
AppBar.Action(
|
||||
title = stringResource(MR.strings.action_web_view),
|
||||
onClick = { func() },
|
||||
icon = Icons.Outlined.Public,
|
||||
),
|
||||
)
|
||||
// KMK <--
|
||||
|
||||
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() },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// KMK -->
|
||||
add(
|
||||
AppBar.OverflowAction(
|
||||
title = stringResource(MR.strings.pref_incognito_mode),
|
||||
onClick = onToggleIncognito,
|
||||
),
|
||||
)
|
||||
// KMK <--
|
||||
|
||||
onSortFeedClick?.let { func ->
|
||||
add(
|
||||
AppBar.OverflowAction(
|
||||
title = stringResource(KMR.strings.action_sort_feed),
|
||||
onClick = { func() },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
onSourceSettingClick?.let { func ->
|
||||
add(
|
||||
AppBar.OverflowAction(
|
||||
title = stringResource(MR.strings.label_settings),
|
||||
onClick = { func() },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
.build(),
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -39,27 +39,26 @@ fun BrowseSourceSimpleToolbar(
|
|||
var selectingDisplayMode by remember { mutableStateOf(false) }
|
||||
// KMK -->
|
||||
AppBarActions(
|
||||
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),
|
||||
)
|
||||
}
|
||||
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 },
|
||||
),
|
||||
)
|
||||
}
|
||||
toggleSelectionMode?.let { mode ->
|
||||
add(
|
||||
bulkSelectionButton(isRunning, mode),
|
||||
)
|
||||
}
|
||||
}
|
||||
.build(),
|
||||
)
|
||||
// KMK <--
|
||||
|
|
|
|||
|
|
@ -30,53 +30,51 @@ 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,
|
||||
)
|
||||
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()
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
add(
|
||||
AppBar.Action(
|
||||
title = stringResource(MR.strings.add_to_library),
|
||||
icon = Icons.Filled.Favorite,
|
||||
onClick = {
|
||||
if (selectedCount > 0) {
|
||||
onChangeCategoryClick()
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}.build(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}.build(),
|
||||
)
|
||||
},
|
||||
isActionMode = true,
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ 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) ->
|
||||
|
|
|
|||
|
|
@ -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 <--
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ fun LibraryUpdateErrorScreen(
|
|||
onInvertSelection: () -> Unit,
|
||||
onErrorsDelete: () -> Unit,
|
||||
onErrorDelete: (Long) -> Unit,
|
||||
onErrorSelected: (LibraryUpdateErrorItem, Boolean, Boolean, Boolean) -> Unit,
|
||||
onErrorSelected: (LibraryUpdateErrorItem, Boolean, Boolean) -> Unit,
|
||||
navigateUp: () -> Unit,
|
||||
) {
|
||||
BackHandler(enabled = state.selectionMode, onBack = { onSelectAll(false) })
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ import tachiyomi.presentation.core.util.selectedBackground
|
|||
internal fun LazyListScope.libraryUpdateErrorUiItems(
|
||||
uiModels: List<LibraryUpdateErrorUiModel>,
|
||||
selectionMode: Boolean,
|
||||
onErrorSelected: (LibraryUpdateErrorItem, Boolean, Boolean, Boolean) -> Unit,
|
||||
onErrorSelected: (LibraryUpdateErrorItem, Boolean, Boolean) -> Unit,
|
||||
onClick: (LibraryUpdateErrorItem) -> Unit,
|
||||
onClickCover: (LibraryUpdateErrorItem) -> Unit,
|
||||
onDelete: (Long) -> Unit,
|
||||
|
|
@ -81,7 +81,6 @@ internal fun LazyListScope.libraryUpdateErrorUiItems(
|
|||
selectionMode -> onErrorSelected(
|
||||
libraryUpdateErrorItem,
|
||||
!libraryUpdateErrorItem.selected,
|
||||
true,
|
||||
false,
|
||||
)
|
||||
|
||||
|
|
@ -93,7 +92,6 @@ internal fun LazyListScope.libraryUpdateErrorUiItems(
|
|||
libraryUpdateErrorItem,
|
||||
!libraryUpdateErrorItem.selected,
|
||||
true,
|
||||
true,
|
||||
)
|
||||
},
|
||||
onClickCover = { onClickCover(libraryUpdateErrorItem) }.takeIf { !selectionMode },
|
||||
|
|
@ -144,8 +142,8 @@ private fun LibraryUpdateErrorUiItem(
|
|||
.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = {
|
||||
onLongClick()
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onLongClick()
|
||||
},
|
||||
)
|
||||
.padding(horizontal = MaterialTheme.padding.medium),
|
||||
|
|
|
|||
|
|
@ -94,9 +94,7 @@ 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
|
||||
|
|
@ -108,12 +106,10 @@ 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
|
||||
|
|
@ -194,7 +190,7 @@ fun MangaScreen(
|
|||
onChapterSwipe: (ChapterList.Item, LibraryPreferences.ChapterSwipeAction) -> Unit,
|
||||
|
||||
// Chapter selection
|
||||
onChapterSelected: (ChapterList.Item, Boolean, Boolean, Boolean) -> Unit,
|
||||
onChapterSelected: (ChapterList.Item, Boolean, Boolean) -> Unit,
|
||||
onAllChapterSelected: (Boolean) -> Unit,
|
||||
onInvertSelection: () -> Unit,
|
||||
|
||||
|
|
@ -404,7 +400,7 @@ private fun MangaScreenSmallImpl(
|
|||
onChapterSwipe: (ChapterList.Item, LibraryPreferences.ChapterSwipeAction) -> Unit,
|
||||
|
||||
// Chapter selection
|
||||
onChapterSelected: (ChapterList.Item, Boolean, Boolean, Boolean) -> Unit,
|
||||
onChapterSelected: (ChapterList.Item, Boolean, Boolean) -> Unit,
|
||||
onAllChapterSelected: (Boolean) -> Unit,
|
||||
onInvertSelection: () -> Unit,
|
||||
|
||||
|
|
@ -868,7 +864,7 @@ private fun MangaScreenLargeImpl(
|
|||
onChapterSwipe: (ChapterList.Item, LibraryPreferences.ChapterSwipeAction) -> Unit,
|
||||
|
||||
// Chapter selection
|
||||
onChapterSelected: (ChapterList.Item, Boolean, Boolean, Boolean) -> Unit,
|
||||
onChapterSelected: (ChapterList.Item, Boolean, Boolean) -> Unit,
|
||||
onAllChapterSelected: (Boolean) -> Unit,
|
||||
onInvertSelection: () -> Unit,
|
||||
|
||||
|
|
@ -1307,7 +1303,7 @@ private fun LazyListScope.sharedChapterItems(
|
|||
// SY <--
|
||||
onChapterClicked: (Chapter) -> Unit,
|
||||
onDownloadChapter: ((List<ChapterList.Item>, ChapterDownloadAction) -> Unit)?,
|
||||
onChapterSelected: (ChapterList.Item, Boolean, Boolean, Boolean) -> Unit,
|
||||
onChapterSelected: (ChapterList.Item, Boolean, Boolean) -> Unit,
|
||||
onChapterSwipe: (ChapterList.Item, LibraryPreferences.ChapterSwipeAction) -> Unit,
|
||||
) {
|
||||
items(
|
||||
|
|
@ -1376,14 +1372,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, true, false) },
|
||||
onToggleSelection = { onChapterSelected(item, !item.selected, false) },
|
||||
onChapterClicked = onChapterClicked,
|
||||
)
|
||||
},
|
||||
|
|
@ -1438,15 +1434,9 @@ 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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ enum class DownloadAction {
|
|||
NEXT_10_CHAPTERS,
|
||||
NEXT_25_CHAPTERS,
|
||||
UNREAD_CHAPTERS,
|
||||
BOOKMARKED_CHAPTERS,
|
||||
}
|
||||
|
||||
enum class EditCoverAction {
|
||||
|
|
|
|||
|
|
@ -43,8 +43,8 @@ fun BaseMangaListItem(
|
|||
onClick = onClickItem,
|
||||
// KMK -->
|
||||
onLongClick = {
|
||||
onLongClick()
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onLongClick()
|
||||
},
|
||||
// KMK <--
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.ExtensionReposScreen
|
||||
import eu.kanade.presentation.more.settings.screen.browse.ExtensionStoresScreen
|
||||
import eu.kanade.tachiyomi.ui.category.sources.SourceCategoryScreen
|
||||
import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.authenticate
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import mihon.domain.extensionrepo.interactor.GetExtensionRepoCount
|
||||
import mihon.domain.extension.interactor.GetExtensionStoreCountAsFlow
|
||||
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 getExtensionRepoCount = remember { Injekt.get<GetExtensionRepoCount>() }
|
||||
val getExtensionStoreCountAsFlow = remember { Injekt.get<GetExtensionStoreCountAsFlow>() }
|
||||
|
||||
val reposCount by getExtensionRepoCount.subscribe().collectAsState(0)
|
||||
val reposCount by getExtensionStoreCountAsFlow().collectAsState(0)
|
||||
|
||||
// SY -->
|
||||
val scope = rememberCoroutineScope()
|
||||
|
|
@ -142,10 +142,10 @@ object SettingsBrowseScreen : SearchableSettings {
|
|||
title = stringResource(MR.strings.pref_hide_in_library_items),
|
||||
),
|
||||
Preference.PreferenceItem.TextPreference(
|
||||
title = stringResource(MR.strings.label_extension_repos),
|
||||
subtitle = pluralStringResource(MR.plurals.num_repos, reposCount, reposCount),
|
||||
title = stringResource(MR.strings.extensionStores),
|
||||
subtitle = pluralStringResource(MR.plurals.num_repos, reposCount.toInt(), reposCount),
|
||||
onClick = {
|
||||
navigator.push(ExtensionReposScreen())
|
||||
navigator.push(ExtensionStoresScreen())
|
||||
},
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ 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
|
||||
|
|
@ -77,6 +78,7 @@ object SettingsDownloadScreen : SearchableSettings {
|
|||
allCategories = allCategories,
|
||||
),
|
||||
getDownloadAheadGroup(downloadPreferences = downloadPreferences),
|
||||
getLibraryUpdateDownloadGroup(downloadPreferences = downloadPreferences),
|
||||
// KMK -->
|
||||
getDownloadCacheRenewInterval(downloadPreferences = downloadPreferences),
|
||||
// KMK <--
|
||||
|
|
@ -216,6 +218,39 @@ 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(
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ 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
|
||||
|
|
@ -101,6 +102,7 @@ object SettingsReaderScreen : SearchableSettings {
|
|||
),
|
||||
SY <-- */
|
||||
getDisplayGroup(readerPreferences = readerPref),
|
||||
getReadingEtaTuningGroup(),
|
||||
getEInkGroup(readerPreferences = readerPref),
|
||||
getReadingGroup(readerPreferences = readerPref),
|
||||
getPagedGroup(readerPreferences = readerPref),
|
||||
|
|
@ -177,6 +179,56 @@ object SettingsReaderScreen : SearchableSettings {
|
|||
)
|
||||
}
|
||||
|
||||
@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) },
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun getEInkGroup(readerPreferences: ReaderPreferences): Preference.PreferenceGroup {
|
||||
val flashPageState by readerPreferences.flashOnPageChange().collectAsState()
|
||||
|
|
|
|||
|
|
@ -1,205 +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 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -8,20 +8,18 @@ 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.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.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.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 ExtensionReposScreen(
|
||||
class ExtensionStoresScreen(
|
||||
private val url: String? = null,
|
||||
) : Screen() {
|
||||
|
||||
|
|
@ -30,80 +28,72 @@ class ExtensionReposScreen(
|
|||
val context = LocalContext.current
|
||||
val navigator = LocalNavigator.currentOrThrow
|
||||
|
||||
val screenModel = rememberScreenModel { ExtensionReposScreenModel() }
|
||||
val screenModel = rememberScreenModel { ExtensionStoresScreenModel() }
|
||||
val state by screenModel.state.collectAsState()
|
||||
|
||||
LaunchedEffect(url) {
|
||||
url?.let { screenModel.showDialog(RepoDialog.Confirm(it)) }
|
||||
url?.let { screenModel.addFromDeeplink(url) }
|
||||
}
|
||||
|
||||
if (state is RepoScreenState.Loading) {
|
||||
if (state is ExtensionStoreScreenState.Loading) {
|
||||
LoadingScreen()
|
||||
return
|
||||
}
|
||||
|
||||
val successState = state as RepoScreenState.Success
|
||||
val successState = state as ExtensionStoreScreenState.Success
|
||||
|
||||
ExtensionReposScreen(
|
||||
ExtensionStoresScreen(
|
||||
state = successState,
|
||||
onClickCreate = { screenModel.showDialog(RepoDialog.Create) },
|
||||
onOpenWebsite = { context.openInBrowser(it.website) },
|
||||
onClickDelete = { screenModel.showDialog(RepoDialog.Delete(it)) },
|
||||
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) },
|
||||
// KMK -->
|
||||
onClickEnable = {
|
||||
screenModel.enableRepo(it)
|
||||
screenModel.enableStore(it.indexUrl)
|
||||
screenModel.refreshExtensionList()
|
||||
context.toast(KMR.strings.extensions_page_need_refresh)
|
||||
},
|
||||
onClickDisable = {
|
||||
screenModel.disableRepo(it)
|
||||
screenModel.disableStore(it.indexUrl)
|
||||
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 RepoDialog.Create -> {
|
||||
ExtensionRepoCreateDialog(
|
||||
is ExtensionStoreDialog.Create -> {
|
||||
ExtensionStoreCreateDialog(
|
||||
onDismissRequest = screenModel::dismissDialog,
|
||||
onCreate = { screenModel.createRepo(it) },
|
||||
repoUrls = successState.repos.map { it.baseUrl }.toImmutableSet(),
|
||||
storeIndexUrls = successState.stores.map { it.indexUrl }.toSet(),
|
||||
processing = dialog.processing,
|
||||
errorMessage = dialog.errorMessage,
|
||||
)
|
||||
}
|
||||
is RepoDialog.Delete -> {
|
||||
ExtensionRepoDeleteDialog(
|
||||
is ExtensionStoreDialog.Delete -> {
|
||||
ExtensionStoreDeleteDialog(
|
||||
onDismissRequest = screenModel::dismissDialog,
|
||||
onDelete = { screenModel.deleteRepo(dialog.repo) },
|
||||
repo = dialog.repo,
|
||||
onDelete = { screenModel.deleteRepo(dialog.store.indexUrl) },
|
||||
storeName = dialog.store.name,
|
||||
storeIndexUrl = dialog.store.indexUrl,
|
||||
)
|
||||
}
|
||||
is RepoDialog.Conflict -> {
|
||||
ExtensionRepoConflictDialog(
|
||||
onDismissRequest = screenModel::dismissDialog,
|
||||
onMigrate = { screenModel.replaceRepo(dialog.newRepo) },
|
||||
oldRepo = dialog.oldRepo,
|
||||
newRepo = dialog.newRepo,
|
||||
)
|
||||
}
|
||||
is RepoDialog.Confirm -> {
|
||||
ExtensionRepoConfirmDialog(
|
||||
is ExtensionStoreDialog.Confirm -> {
|
||||
ExtensionStoreConfirmDialog(
|
||||
onDismissRequest = screenModel::dismissDialog,
|
||||
onCreate = { screenModel.createRepo(dialog.url) },
|
||||
repo = dialog.url,
|
||||
storeIndexUrl = dialog.url,
|
||||
storeAlreadyExists = dialog.alreadyExists,
|
||||
processing = dialog.processing,
|
||||
errorMessage = dialog.errorMessage,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
screenModel.events.collectLatest { event ->
|
||||
if (event is RepoEvent.LocalizedMessage) {
|
||||
context.toast(event.stringRes)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,223 @@
|
|||
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()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,188 +0,0 @@
|
|||
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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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.graphics.ImageBitmap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.imageResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
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 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 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 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 ExtensionReposContent(
|
||||
repos: ImmutableSet<ExtensionRepo>,
|
||||
fun ExtensionStoresContent(
|
||||
repos: List<ExtensionStore>,
|
||||
lazyListState: LazyListState,
|
||||
paddingValues: PaddingValues,
|
||||
onOpenWebsite: (ExtensionRepo) -> Unit,
|
||||
onClickDelete: (String) -> Unit,
|
||||
onCopy: (ExtensionStore) -> Unit,
|
||||
onOpenWebsite: (ExtensionStore) -> Unit,
|
||||
onOpenDiscord: (ExtensionStore) -> Unit,
|
||||
onClickDelete: (ExtensionStore) -> Unit,
|
||||
// KMK -->
|
||||
onClickEnable: (String) -> Unit,
|
||||
onClickDisable: (String) -> Unit,
|
||||
onClickEnable: (ExtensionStore) -> Unit,
|
||||
onClickDisable: (ExtensionStore) -> Unit,
|
||||
disabledRepos: Set<String>,
|
||||
// KMK <--
|
||||
modifier: Modifier = Modifier,
|
||||
|
|
@ -65,15 +65,17 @@ fun ExtensionReposContent(
|
|||
) {
|
||||
repos.forEach {
|
||||
item {
|
||||
ExtensionRepoListItem(
|
||||
ExtensionStoresListItem(
|
||||
modifier = Modifier.animateItem(),
|
||||
repo = it,
|
||||
store = it,
|
||||
onOpenWebsite = { onOpenWebsite(it) },
|
||||
onDelete = { onClickDelete(it.baseUrl) },
|
||||
onOpenDiscord = { onOpenDiscord(it) },
|
||||
onCopy = { onCopy(it) },
|
||||
onDelete = { onClickDelete(it) },
|
||||
// KMK -->
|
||||
onEnable = { onClickEnable(it.baseUrl) },
|
||||
onDisable = { onClickDisable(it.baseUrl) },
|
||||
isDisabled = it.baseUrl in disabledRepos,
|
||||
onEnable = { onClickEnable(it) },
|
||||
onDisable = { onClickDisable(it) },
|
||||
isDisabled = it.indexUrl in disabledRepos,
|
||||
// KMK <--
|
||||
)
|
||||
}
|
||||
|
|
@ -82,9 +84,11 @@ fun ExtensionReposContent(
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun ExtensionRepoListItem(
|
||||
repo: ExtensionRepo,
|
||||
private fun ExtensionStoresListItem(
|
||||
store: ExtensionStore,
|
||||
onOpenWebsite: () -> Unit,
|
||||
onOpenDiscord: () -> Unit,
|
||||
onCopy: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
// KMK -->
|
||||
|
|
@ -93,8 +97,6 @@ private fun ExtensionRepoListItem(
|
|||
onDisable: () -> Unit,
|
||||
// KMK <--
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
ElevatedCard(
|
||||
modifier = modifier,
|
||||
) {
|
||||
|
|
@ -103,9 +105,9 @@ private fun ExtensionRepoListItem(
|
|||
modifier = Modifier
|
||||
.padding(start = MaterialTheme.padding.medium),
|
||||
) {
|
||||
val resId = repoResId(repo.signingKeyFingerprint)
|
||||
val resId = repoResId(store.signingKey)
|
||||
Image(
|
||||
bitmap = ImageBitmap.imageResource(id = resId),
|
||||
painter = painterResource(id = resId),
|
||||
contentDescription = null,
|
||||
alpha = if (isDisabled) 0.4f else 1f,
|
||||
modifier = Modifier
|
||||
|
|
@ -126,7 +128,7 @@ private fun ExtensionRepoListItem(
|
|||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = repo.name,
|
||||
text = store.name,
|
||||
// KMK: modifier = Modifier.padding(start = MaterialTheme.padding.medium),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
// KMK -->
|
||||
|
|
@ -142,17 +144,21 @@ private fun ExtensionRepoListItem(
|
|||
) {
|
||||
IconButton(onClick = onOpenWebsite) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Outlined.OpenInNew,
|
||||
imageVector = Icons.Outlined.Public,
|
||||
contentDescription = stringResource(MR.strings.action_open_in_browser),
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
val url = "${repo.baseUrl}/index.min.json"
|
||||
context.copyToClipboard(url, url)
|
||||
},
|
||||
) {
|
||||
if (store.contact.discord != null) {
|
||||
IconButton(onClick = onOpenDiscord) {
|
||||
Icon(
|
||||
imageVector = CustomIcons.Discord,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(onClick = onCopy) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.ContentCopy,
|
||||
contentDescription = stringResource(MR.strings.action_copy_to_clipboard),
|
||||
|
|
@ -190,16 +196,18 @@ fun repoResId(signKey: String) = when (signKey) {
|
|||
@Preview
|
||||
@Composable
|
||||
fun ExtensionReposContentPreview() {
|
||||
val repos = persistentSetOf(
|
||||
ExtensionRepo("https://repo", "Komikku", "", "", KOMIKKU_SIGNATURE),
|
||||
ExtensionRepo("https://repo", "Repo", "", "", REPO_SIGNATURE),
|
||||
ExtensionRepo("https://repo", "Other", "", "", "key2"),
|
||||
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),
|
||||
)
|
||||
ExtensionReposContent(
|
||||
ExtensionStoresContent(
|
||||
repos = repos,
|
||||
lazyListState = LazyListState(),
|
||||
paddingValues = PaddingValues(),
|
||||
onCopy = {},
|
||||
onOpenWebsite = {},
|
||||
onOpenDiscord = {},
|
||||
onClickDelete = {},
|
||||
onClickEnable = {},
|
||||
onClickDisable = {},
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
@file:JvmName("ExtensionReposScreenKt")
|
||||
|
||||
package eu.kanade.presentation.more.settings.screen.browse.components
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
|
|
@ -21,13 +19,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.RepoScreenState
|
||||
import eu.kanade.presentation.more.settings.screen.browse.ExtensionStoreScreenState
|
||||
import eu.kanade.tachiyomi.util.system.openInBrowser
|
||||
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 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 tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.components.material.Scaffold
|
||||
import tachiyomi.presentation.core.components.material.padding
|
||||
|
|
@ -37,14 +35,16 @@ import tachiyomi.presentation.core.screens.EmptyScreen
|
|||
import tachiyomi.presentation.core.util.plus
|
||||
|
||||
@Composable
|
||||
fun ExtensionReposScreen(
|
||||
state: RepoScreenState.Success,
|
||||
fun ExtensionStoresScreen(
|
||||
state: ExtensionStoreScreenState.Success,
|
||||
onClickCreate: () -> Unit,
|
||||
onOpenWebsite: (ExtensionRepo) -> Unit,
|
||||
onClickDelete: (String) -> Unit,
|
||||
onCopy: (ExtensionStore) -> Unit,
|
||||
onOpenWebsite: (ExtensionStore) -> Unit,
|
||||
onOpenDiscord: (ExtensionStore) -> Unit,
|
||||
onClickDelete: (ExtensionStore) -> Unit,
|
||||
// KMK -->
|
||||
onClickEnable: (String) -> Unit,
|
||||
onClickDisable: (String) -> Unit,
|
||||
onClickEnable: (ExtensionStore) -> Unit,
|
||||
onClickDisable: (ExtensionStore) -> Unit,
|
||||
// KMK <--
|
||||
onClickRefresh: () -> Unit,
|
||||
navigateUp: () -> Unit,
|
||||
|
|
@ -54,7 +54,7 @@ fun ExtensionReposScreen(
|
|||
topBar = { scrollBehavior ->
|
||||
AppBar(
|
||||
navigateUp = navigateUp,
|
||||
title = stringResource(MR.strings.label_extension_repos),
|
||||
title = stringResource(MR.strings.extensionStores),
|
||||
scrollBehavior = scrollBehavior,
|
||||
actions = {
|
||||
IconButton(onClick = onClickRefresh) {
|
||||
|
|
@ -76,7 +76,7 @@ fun ExtensionReposScreen(
|
|||
if (state.isEmpty) {
|
||||
val context = LocalContext.current
|
||||
EmptyScreen(
|
||||
MR.strings.information_empty_repos,
|
||||
MR.strings.extensionStoresScreen_emptyLabel,
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
// KMK -->
|
||||
help = {
|
||||
|
|
@ -94,12 +94,14 @@ fun ExtensionReposScreen(
|
|||
return@Scaffold
|
||||
}
|
||||
|
||||
ExtensionReposContent(
|
||||
repos = state.repos,
|
||||
ExtensionStoresContent(
|
||||
repos = state.stores,
|
||||
lazyListState = lazyListState,
|
||||
paddingValues = paddingValues + topSmallPaddingValues +
|
||||
PaddingValues(horizontal = MaterialTheme.padding.medium),
|
||||
onCopy = onCopy,
|
||||
onOpenWebsite = onOpenWebsite,
|
||||
onOpenDiscord = onOpenDiscord,
|
||||
onClickDelete = onClickDelete,
|
||||
// KMK -->
|
||||
onClickEnable = onClickEnable,
|
||||
|
|
@ -113,19 +115,21 @@ fun ExtensionReposScreen(
|
|||
// KMK -->
|
||||
@Preview
|
||||
@Composable
|
||||
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"),
|
||||
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),
|
||||
),
|
||||
disabledRepos = setOf("https://repo"),
|
||||
)
|
||||
ExtensionReposScreen(
|
||||
ExtensionStoresScreen(
|
||||
state = state,
|
||||
onClickCreate = { },
|
||||
onCopy = { },
|
||||
onOpenWebsite = { },
|
||||
onOpenDiscord = { },
|
||||
onClickDelete = { },
|
||||
onClickEnable = { },
|
||||
onClickDisable = { },
|
||||
|
|
@ -136,12 +140,14 @@ private fun ExtensionReposScreenPreview() {
|
|||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ExtensionReposScreenEmptyPreview() {
|
||||
val state = RepoScreenState.Success(repos = persistentSetOf())
|
||||
ExtensionReposScreen(
|
||||
private fun ExtensionStoresScreenEmptyPreview() {
|
||||
val state = ExtensionStoreScreenState.Success(stores = persistentListOf())
|
||||
ExtensionStoresScreen(
|
||||
state = state,
|
||||
onClickCreate = { },
|
||||
onCopy = { },
|
||||
onOpenWebsite = { },
|
||||
onOpenDiscord = { },
|
||||
onClickDelete = { },
|
||||
onClickEnable = { },
|
||||
onClickDisable = { },
|
||||
|
|
@ -114,7 +114,7 @@ private class SyncSettingsSelectorModel(
|
|||
tracking = syncSettings.tracking,
|
||||
history = syncSettings.history,
|
||||
appSettings = syncSettings.appSettings,
|
||||
extensionRepoSettings = syncSettings.extensionRepoSettings,
|
||||
extensionStores = syncSettings.extensionStores,
|
||||
sourceSettings = syncSettings.sourceSettings,
|
||||
privateSettings = syncSettings.privateSettings,
|
||||
|
||||
|
|
@ -134,7 +134,7 @@ private class SyncSettingsSelectorModel(
|
|||
tracking = backupOptions.tracking,
|
||||
history = backupOptions.history,
|
||||
appSettings = backupOptions.appSettings,
|
||||
extensionRepoSettings = backupOptions.extensionRepoSettings,
|
||||
extensionStores = backupOptions.extensionStores,
|
||||
sourceSettings = backupOptions.sourceSettings,
|
||||
privateSettings = backupOptions.privateSettings,
|
||||
|
||||
|
|
|
|||
|
|
@ -171,7 +171,6 @@ internal fun LazyListScope.updatesUiItems(
|
|||
// KMK -->
|
||||
UpdateSelectionOptions(
|
||||
selected = !updatesItem.selected,
|
||||
userSelected = true,
|
||||
fromLongPress = true,
|
||||
isGroup = isLeader && item.isExpandable,
|
||||
isExpanded = isExpanded,
|
||||
|
|
@ -186,7 +185,6 @@ internal fun LazyListScope.updatesUiItems(
|
|||
// KMK -->
|
||||
UpdateSelectionOptions(
|
||||
selected = !updatesItem.selected,
|
||||
userSelected = true,
|
||||
fromLongPress = false,
|
||||
isGroup = isLeader && item.isExpandable,
|
||||
isExpanded = isExpanded,
|
||||
|
|
@ -288,8 +286,8 @@ private fun UpdatesUiItem(
|
|||
.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = {
|
||||
onLongClick()
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onLongClick()
|
||||
},
|
||||
)
|
||||
.padding(top = if (isLeader) MaterialTheme.padding.small else 0.dp)
|
||||
|
|
@ -311,7 +309,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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ fun EhLoginWebViewScreen(
|
|||
)
|
||||
is LoadingState.Loading -> {
|
||||
val animatedProgress by animateFloatAsState(
|
||||
(loadingState as? LoadingState.Loading)?.progress ?: 1f,
|
||||
loadingState.progress,
|
||||
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
|
||||
label = "webview_loading",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.ExtensionRepoBackupCreator
|
||||
import eu.kanade.tachiyomi.data.backup.create.creators.ExtensionStoresBackupCreator
|
||||
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.BackupExtensionRepos
|
||||
import eu.kanade.tachiyomi.data.backup.models.BackupExtensionStore
|
||||
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 extensionRepoBackupCreator: ExtensionRepoBackupCreator = ExtensionRepoBackupCreator(),
|
||||
private val extensionStoresBackupCreator: ExtensionStoresBackupCreator = ExtensionStoresBackupCreator(),
|
||||
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),
|
||||
backupExtensionRepo = backupExtensionRepos(options),
|
||||
backupExtensionStores = backupExtensionStores(options),
|
||||
backupSourcePreferences = backupSourcePreferences(options),
|
||||
|
||||
// SY -->
|
||||
|
|
@ -159,16 +159,16 @@ class BackupCreator(
|
|||
return sourcesBackupCreator(mangas)
|
||||
}
|
||||
|
||||
/* KMK --> */ suspend /* KMK <-- */ fun backupAppPreferences(options: BackupOptions): List<BackupPreference> {
|
||||
internal fun backupAppPreferences(options: BackupOptions): List<BackupPreference> {
|
||||
if (!options.appSettings) return emptyList()
|
||||
|
||||
return preferenceBackupCreator.createApp(includePrivatePreferences = options.privateSettings)
|
||||
}
|
||||
|
||||
suspend fun backupExtensionRepos(options: BackupOptions): List<BackupExtensionRepos> {
|
||||
if (!options.extensionRepoSettings) return emptyList()
|
||||
internal suspend fun backupExtensionStores(options: BackupOptions): List<BackupExtensionStore> {
|
||||
if (!options.extensionStores) return emptyList()
|
||||
|
||||
return extensionRepoBackupCreator()
|
||||
return extensionStoresBackupCreator()
|
||||
}
|
||||
|
||||
fun backupSourcePreferences(options: BackupOptions): List<BackupSourcePreferences> {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ data class BackupOptions(
|
|||
val history: Boolean = true,
|
||||
val readEntries: Boolean = true,
|
||||
val appSettings: Boolean = true,
|
||||
val extensionRepoSettings: Boolean = true,
|
||||
val extensionStores: Boolean = true,
|
||||
val sourceSettings: Boolean = true,
|
||||
val privateSettings: Boolean = false,
|
||||
// SY -->
|
||||
|
|
@ -31,7 +31,7 @@ data class BackupOptions(
|
|||
history,
|
||||
readEntries,
|
||||
appSettings,
|
||||
extensionRepoSettings,
|
||||
extensionStores,
|
||||
sourceSettings,
|
||||
privateSettings,
|
||||
// SY -->
|
||||
|
|
@ -41,7 +41,7 @@ data class BackupOptions(
|
|||
)
|
||||
|
||||
fun canCreate() =
|
||||
libraryEntries || categories || appSettings || extensionRepoSettings || sourceSettings || savedSearchesFeeds
|
||||
libraryEntries || categories || appSettings || extensionStores || 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.extensionRepo_settings,
|
||||
getter = BackupOptions::extensionRepoSettings,
|
||||
setter = { options, enabled -> options.copy(extensionRepoSettings = enabled) },
|
||||
label = MR.strings.extensionStores,
|
||||
getter = BackupOptions::extensionStores,
|
||||
setter = { options, enabled -> options.copy(extensionStores = enabled) },
|
||||
),
|
||||
Entry(
|
||||
label = MR.strings.source_settings,
|
||||
|
|
@ -128,7 +128,7 @@ data class BackupOptions(
|
|||
history = array[4],
|
||||
readEntries = array[5],
|
||||
appSettings = array[6],
|
||||
extensionRepoSettings = array[7],
|
||||
extensionStores = array[7],
|
||||
sourceSettings = array[8],
|
||||
privateSettings = array[9],
|
||||
// SY -->
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -108,7 +108,12 @@ class MangaBackupCreator(
|
|||
if (historyByMangaId.isNotEmpty()) {
|
||||
val history = historyByMangaId.map { history ->
|
||||
val chapter = handler.awaitOne { chaptersQueries.getChapterById(history.chapterId) }
|
||||
BackupHistory(chapter.url, history.readAt?.time ?: 0L, history.readDuration)
|
||||
BackupHistory(
|
||||
url = chapter.url,
|
||||
lastRead = history.readAt?.time ?: 0L,
|
||||
readDuration = history.readDuration,
|
||||
readPages = history.readPages,
|
||||
)
|
||||
}
|
||||
if (history.isNotEmpty()) {
|
||||
mangaObject.history = history
|
||||
|
|
|
|||
|
|
@ -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 backupExtensionRepo: List<BackupExtensionRepos> = emptyList(),
|
||||
@ProtoNumber(106) var backupExtensionStores: List<BackupExtensionStore> = emptyList(),
|
||||
// SY specific values
|
||||
@ProtoNumber(600) var backupSavedSearches: List<BackupSavedSearch> = emptyList(),
|
||||
// KMK -->
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
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,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
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,
|
||||
)
|
||||
}
|
||||
|
|
@ -10,11 +10,13 @@ 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.BackupExtensionRepos
|
||||
import eu.kanade.tachiyomi.data.backup.models.BackupExtensionStore
|
||||
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.ExtensionRepoRestorer
|
||||
import eu.kanade.tachiyomi.data.backup.restore.restorers.ExtensionStoreRestorer
|
||||
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 extensionRepoRestorer: ExtensionRepoRestorer = ExtensionRepoRestorer(),
|
||||
private val extensionStoreRestorer: ExtensionStoreRestorer = ExtensionStoreRestorer(),
|
||||
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.extensionRepoSettings) {
|
||||
restoreAmount += backup.backupExtensionRepo.size
|
||||
if (options.extensionStores) {
|
||||
restoreAmount += backup.backupExtensionStores.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.extensionRepoSettings) {
|
||||
restoreExtensionRepos(backup.backupExtensionRepo)
|
||||
if (options.extensionStores) {
|
||||
restoreExtensionStores(backup.backupExtensionStores)
|
||||
}
|
||||
|
||||
// TODO: optionally trigger online library + tracker update
|
||||
|
|
@ -248,23 +248,25 @@ class BackupRestorer(
|
|||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.restoreExtensionRepos(
|
||||
backupExtensionRepo: List<BackupExtensionRepos>,
|
||||
private fun CoroutineScope.restoreExtensionStores(
|
||||
backupExtensionStores: List<BackupExtensionStore>,
|
||||
) = launch {
|
||||
backupExtensionRepo
|
||||
backupExtensionStores
|
||||
.forEach {
|
||||
ensureActive()
|
||||
|
||||
try {
|
||||
extensionRepoRestorer(it)
|
||||
extensionStoreRestorer(it)
|
||||
} catch (e: Exception) {
|
||||
errors.add(Date() to "Error Adding Repo: ${it.name} : ${e.message}")
|
||||
errors.add(Date() to "Error adding extension store: ${it.name} : ${e.message}")
|
||||
}
|
||||
|
||||
restoreProgress += 1
|
||||
// KMK -->
|
||||
with(notifier) {
|
||||
// KMK <--
|
||||
showRestoreProgress(
|
||||
context.stringResource(MR.strings.extensionRepo_settings),
|
||||
context.stringResource(MR.strings.extensionStores),
|
||||
restoreProgress,
|
||||
restoreAmount,
|
||||
isSync,
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ data class RestoreOptions(
|
|||
val libraryEntries: Boolean = true,
|
||||
val categories: Boolean = true,
|
||||
val appSettings: Boolean = true,
|
||||
val extensionRepoSettings: Boolean = true,
|
||||
val extensionStores: Boolean = true,
|
||||
val sourceSettings: Boolean = true,
|
||||
// SY -->
|
||||
val savedSearchesFeeds: Boolean = true,
|
||||
|
|
@ -20,7 +20,7 @@ data class RestoreOptions(
|
|||
libraryEntries,
|
||||
categories,
|
||||
appSettings,
|
||||
extensionRepoSettings,
|
||||
extensionStores,
|
||||
sourceSettings,
|
||||
// SY -->
|
||||
savedSearchesFeeds,
|
||||
|
|
@ -31,7 +31,7 @@ data class RestoreOptions(
|
|||
libraryEntries ||
|
||||
categories ||
|
||||
appSettings ||
|
||||
extensionRepoSettings ||
|
||||
extensionStores ||
|
||||
sourceSettings /* SY --> */ ||
|
||||
savedSearchesFeeds /* SY <-- */
|
||||
|
||||
|
|
@ -53,9 +53,9 @@ data class RestoreOptions(
|
|||
setter = { options, enabled -> options.copy(appSettings = enabled) },
|
||||
),
|
||||
Entry(
|
||||
label = MR.strings.extensionRepo_settings,
|
||||
getter = RestoreOptions::extensionRepoSettings,
|
||||
setter = { options, enabled -> options.copy(extensionRepoSettings = enabled) },
|
||||
label = MR.strings.extensionStores,
|
||||
getter = RestoreOptions::extensionStores,
|
||||
setter = { options, enabled -> options.copy(extensionStores = enabled) },
|
||||
),
|
||||
Entry(
|
||||
label = MR.strings.source_settings,
|
||||
|
|
@ -77,7 +77,7 @@ data class RestoreOptions(
|
|||
libraryEntries = array[0],
|
||||
categories = array[1],
|
||||
appSettings = array[2],
|
||||
extensionRepoSettings = array[3],
|
||||
extensionStores = array[3],
|
||||
sourceSettings = array[4],
|
||||
// SY -->
|
||||
savedSearchesFeeds = array[5],
|
||||
|
|
|
|||
|
|
@ -1,40 +0,0 @@
|
|||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -427,6 +427,7 @@ 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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -437,6 +438,7 @@ class MangaRestorer(
|
|||
it.chapterId,
|
||||
it.readAt,
|
||||
it.readDuration,
|
||||
it.readPages,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ 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
|
||||
|
|
@ -119,6 +120,7 @@ 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()
|
||||
|
|
@ -465,6 +467,12 @@ 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) {
|
||||
|
|
@ -493,9 +501,10 @@ 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()) {
|
||||
|
|
@ -748,10 +757,6 @@ 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,
|
||||
|
|
@ -765,16 +770,19 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
|
|||
} else {
|
||||
NetworkType.CONNECTED
|
||||
}
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
.build()
|
||||
val constraints = Constraints.Builder()
|
||||
// 'networkRequest' only applies to Android 9+, otherwise 'networkType' is used
|
||||
.setRequiredNetworkRequest(networkRequestBuilder.build(), networkType)
|
||||
.setRequiredNetworkRequest(networkRequest, networkType)
|
||||
.setRequiresCharging(DEVICE_CHARGING in restrictions)
|
||||
.setRequiresBatteryNotLow(true)
|
||||
.build()
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ class SyncManager(
|
|||
chapters = syncOptions.chapters,
|
||||
tracking = syncOptions.tracking,
|
||||
history = syncOptions.history,
|
||||
extensionRepoSettings = syncOptions.extensionRepoSettings,
|
||||
extensionStores = syncOptions.extensionStores,
|
||||
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),
|
||||
|
|
@ -192,7 +192,7 @@ class SyncManager(
|
|||
backupSources = remoteBackup.backupSources,
|
||||
backupPreferences = remoteBackup.backupPreferences,
|
||||
backupSourcePreferences = remoteBackup.backupSourcePreferences,
|
||||
backupExtensionRepo = remoteBackup.backupExtensionRepo,
|
||||
backupExtensionStores = remoteBackup.backupExtensionStores,
|
||||
|
||||
// SY -->
|
||||
backupSavedSearches = remoteBackup.backupSavedSearches,
|
||||
|
|
@ -222,7 +222,7 @@ class SyncManager(
|
|||
appSettings = true,
|
||||
sourceSettings = true,
|
||||
libraryEntries = true,
|
||||
extensionRepoSettings = true,
|
||||
extensionStores = true,
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -5,8 +5,14 @@ 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
|
||||
|
|
@ -21,7 +27,6 @@ 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,
|
||||
|
|
@ -32,9 +37,34 @@ 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()
|
||||
|
||||
|
|
@ -52,11 +82,26 @@ class SyncYomiSyncService(
|
|||
syncData
|
||||
}
|
||||
|
||||
pushSyncData(finalSyncData, etag)
|
||||
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 <--
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -78,7 +123,6 @@ class SyncYomiSyncService(
|
|||
headers = headers,
|
||||
)
|
||||
|
||||
val client = OkHttpClient()
|
||||
val response = client.newCall(downloadRequest).await()
|
||||
|
||||
if (response.code == HttpStatus.SC_NOT_MODIFIED) {
|
||||
|
|
@ -123,13 +167,12 @@ class SyncYomiSyncService(
|
|||
/**
|
||||
* Return true if update success
|
||||
*/
|
||||
private suspend fun pushSyncData(syncData: SyncData, eTag: String) {
|
||||
val backup = syncData.backup ?: return
|
||||
private suspend fun pushSyncData(syncData: SyncData, eTag: String): Boolean {
|
||||
val backup = syncData.backup ?: return true
|
||||
|
||||
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()) {
|
||||
|
|
@ -137,13 +180,6 @@ 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))
|
||||
|
|
@ -156,20 +192,58 @@ class SyncYomiSyncService(
|
|||
body = body,
|
||||
)
|
||||
|
||||
val response = client.newCall(uploadRequest).await()
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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" }
|
||||
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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ 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
|
||||
|
|
@ -26,6 +27,7 @@ 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
|
||||
|
|
@ -43,6 +45,25 @@ 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 = $$"""
|
||||
|
|
@ -71,6 +92,9 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
|||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
// KMK -->
|
||||
.also { it.parseALError() }
|
||||
// KMK <--
|
||||
.parseAs<ALAddMangaResult>()
|
||||
.let {
|
||||
track.library_id = it.data.entry.id
|
||||
|
|
@ -112,6 +136,9 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
|||
}
|
||||
authClient.newCall(POST(API_URL, body = payload.toString().toRequestBody(jsonMime)))
|
||||
.awaitSuccess()
|
||||
// KMK -->
|
||||
.use { it.parseALError() }
|
||||
// KMK <--
|
||||
track
|
||||
}
|
||||
}
|
||||
|
|
@ -134,6 +161,9 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
|||
}
|
||||
authClient.newCall(POST(API_URL, body = payload.toString().toRequestBody(jsonMime)))
|
||||
.awaitSuccess()
|
||||
// KMK -->
|
||||
.use { it.parseALError() }
|
||||
// KMK <--
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -192,6 +222,9 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
|||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
// KMK -->
|
||||
.also { it.parseALError() }
|
||||
// KMK <--
|
||||
.parseAs<ALSearchResult>()
|
||||
.data.page.media
|
||||
.map { it.toALManga().toTrack() }
|
||||
|
|
@ -271,6 +304,9 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
|||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
// KMK -->
|
||||
.also { it.parseALError() }
|
||||
// KMK <--
|
||||
.parseAs<ALUserListMangaQueryResult>()
|
||||
.data.page.mediaList
|
||||
.map { it.toALUserManga() }
|
||||
|
|
@ -312,6 +348,9 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
|||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
// KMK -->
|
||||
.also { it.parseALError() }
|
||||
// KMK <--
|
||||
.parseAs<ALCurrentUserResult>()
|
||||
.let {
|
||||
val viewer = it.data.viewer
|
||||
|
|
@ -323,9 +362,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
|
||||
|
|
@ -365,9 +404,12 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
|||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
// KMK -->
|
||||
.also { it.parseALError() }
|
||||
// KMK <--
|
||||
.parseAs<ALMangaMetadata>()
|
||||
.let {
|
||||
val media = it.data.media
|
||||
.let { metadata ->
|
||||
val media = metadata.data.media
|
||||
TrackMangaMetadata(
|
||||
remoteId = media.id,
|
||||
title = media.title.userPreferred,
|
||||
|
|
@ -392,9 +434,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
|
||||
|
|
@ -430,6 +472,9 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
|||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
// KMK -->
|
||||
.also { it.parseALError() }
|
||||
// KMK <--
|
||||
.parseAs<ALIdSearchResult>()
|
||||
.data.media
|
||||
.toALManga()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
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,
|
||||
)
|
||||
|
|
@ -14,7 +14,9 @@ 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
|
||||
|
|
@ -124,8 +126,23 @@ class MyAnimeListApi(
|
|||
.put(formBodyBuilder.build())
|
||||
.build()
|
||||
with(json) {
|
||||
authClient.newCall(request)
|
||||
.awaitSuccess()
|
||||
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
|
||||
.parseAs<MALListItemStatus>()
|
||||
.let { parseMangaItem(it, track) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,5 +80,6 @@ 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")
|
||||
|
|
|
|||
|
|
@ -93,13 +93,17 @@ 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) {
|
||||
"komikku-app/komikku-preview"
|
||||
FORK_PREVIEW_RELEASE_REPO
|
||||
} else {
|
||||
"komikku-app/komikku"
|
||||
FORK_RELEASE_REPO
|
||||
}
|
||||
|
||||
val RELEASE_TAG: String by lazy { getReleaseTag() }
|
||||
|
|
@ -111,4 +115,4 @@ fun getReleaseTag(peekIntoPreview: Boolean = false): String =
|
|||
"v${BuildConfig.VERSION_NAME}"
|
||||
}
|
||||
|
||||
val RELEASE_URL = "https://github.com/$GITHUB_REPO/releases/tag/$RELEASE_TAG"
|
||||
val RELEASE_URL = "$RELEASES_WEB_BASE_URL/$GITHUB_REPO/releases/tag/$RELEASE_TAG"
|
||||
|
|
|
|||
|
|
@ -128,6 +128,8 @@ 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 {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ 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
|
||||
|
|
@ -61,6 +62,9 @@ class PreferenceModule(val app: Application) : InjektModule {
|
|||
addSingletonFactory {
|
||||
ReaderPreferences(get())
|
||||
}
|
||||
addSingletonFactory {
|
||||
ReadingEtaPreferences(get())
|
||||
}
|
||||
addSingletonFactory {
|
||||
TrackPreferences(get())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
repoUrl = availableExt.repoUrl,
|
||||
// KMK -->
|
||||
store = availableExt.store,
|
||||
isObsolete = false,
|
||||
repoName = extension.repoName ?: availableExt.repoName,
|
||||
// KMK <--
|
||||
storeName = extension.storeName ?: availableExt.storeName,
|
||||
)
|
||||
// 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(api.getApkUrl(extension), extension)
|
||||
return installer.downloadAndInstall(extension.apkUrl, 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 <--
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,24 +6,13 @@ 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 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 mihon.domain.extension.interactor.UpdateExtensionStores
|
||||
import mihon.domain.extension.repository.ExtensionStoreRepository
|
||||
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
|
||||
|
|
@ -32,19 +21,16 @@ import kotlin.time.Duration.Companion.days
|
|||
|
||||
internal class ExtensionApi {
|
||||
|
||||
private val networkService: NetworkHelper by injectLazy()
|
||||
private val repository: ExtensionStoreRepository by injectLazy()
|
||||
|
||||
private val preferenceStore: PreferenceStore by injectLazy()
|
||||
private val getExtensionRepo: GetExtensionRepo by injectLazy()
|
||||
private val updateExtensionRepo: UpdateExtensionRepo by injectLazy()
|
||||
private val updateExtensionStores: UpdateExtensionStores 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)
|
||||
}
|
||||
|
|
@ -54,37 +40,11 @@ internal class ExtensionApi {
|
|||
val disabledRepos = sourcePreferences.disabledRepos().get()
|
||||
// KMK <--
|
||||
return withIOContext {
|
||||
getExtensionRepo.getAll()
|
||||
repository.fetchExtensions(
|
||||
// KMK -->
|
||||
.filterNot { it.baseUrl in disabledRepos }
|
||||
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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -99,8 +59,7 @@ internal class ExtensionApi {
|
|||
return null
|
||||
}
|
||||
|
||||
// Update extension repo details
|
||||
updateExtensionRepo.awaitAll()
|
||||
updateExtensionStores()
|
||||
|
||||
val extensions = if (fromAvailableExtensionList) {
|
||||
extensionManager.availableExtensionsFlow.value
|
||||
|
|
@ -138,47 +97,6 @@ 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(),
|
||||
|
|
@ -194,32 +112,3 @@ 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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,9 @@ abstract class Installer(private val service: Service) {
|
|||
private val extensionManager: ExtensionManager by injectLazy()
|
||||
|
||||
private var waitingInstall = AtomicReference<Entry?>(null)
|
||||
private val queue = Collections.synchronizedList(mutableListOf<Entry>())
|
||||
// KMK -->
|
||||
private val queue = Collections.synchronizedSet(mutableSetOf<Entry>())
|
||||
// KMK <--
|
||||
|
||||
private val cancelReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
|
|
@ -104,7 +106,9 @@ abstract class Installer(private val service: Service) {
|
|||
}
|
||||
val nextEntry = queue.first()
|
||||
if (waitingInstall.compareAndSet(null, nextEntry)) {
|
||||
queue.removeAt(0)
|
||||
// KMK -->
|
||||
queue.remove(nextEntry)
|
||||
// KMK <--
|
||||
processEntry(nextEntry)
|
||||
}
|
||||
}
|
||||
|
|
@ -129,7 +133,11 @@ abstract class Installer(private val service: Service) {
|
|||
*/
|
||||
private fun cancelQueue(downloadId: Long) {
|
||||
val waitingInstall = this.waitingInstall.load()
|
||||
val toCancel = queue.find { it.downloadId == downloadId } ?: waitingInstall ?: return
|
||||
// KMK -->
|
||||
val toCancel = synchronized(queue) { queue.find { it.downloadId == downloadId } }
|
||||
?: waitingInstall?.takeIf { it.downloadId == downloadId }
|
||||
// KMK <--
|
||||
?: return
|
||||
if (cancelEntry(toCancel)) {
|
||||
queue.remove(toCancel)
|
||||
if (waitingInstall == toCancel) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package eu.kanade.tachiyomi.extension.installer
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.BroadcastReceiver
|
||||
|
|
@ -14,8 +15,7 @@ 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 logcat.LogPriority
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import exh.log.xLogE
|
||||
|
||||
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) {
|
||||
logcat(LogPriority.ERROR) { "Fatal error for $intent" }
|
||||
xLogE("Fatal error for $intent")
|
||||
continueQueue(InstallStep.Error)
|
||||
return
|
||||
}
|
||||
|
|
@ -82,6 +82,7 @@ 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,
|
||||
|
|
@ -89,10 +90,11 @@ 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) {
|
||||
logcat(LogPriority.ERROR, e) { "Failed to install extension ${entry.downloadId} ${entry.uri}" }
|
||||
xLogE("Failed to install extension ${entry.downloadId} ${entry.uri}", e)
|
||||
activeSession?.let { (_, sessionId) ->
|
||||
packageInstaller.abandonSession(sessionId)
|
||||
}
|
||||
|
|
@ -103,8 +105,13 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic
|
|||
override fun cancelEntry(entry: Entry): Boolean {
|
||||
activeSession?.let { (activeEntry, sessionId) ->
|
||||
if (activeEntry == entry) {
|
||||
packageInstaller.abandonSession(sessionId)
|
||||
return false
|
||||
return try {
|
||||
packageInstaller.abandonSession(sessionId)
|
||||
false
|
||||
} catch (_: SecurityException) {
|
||||
// Highly likely the session has succeeded
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
|
|
|
|||
|
|
@ -111,20 +111,17 @@ 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 {
|
||||
installer.install(it)
|
||||
shellInterface?.install(it)
|
||||
// KMK -->
|
||||
?: throw Exception("Shell interface is not available")
|
||||
// KMK <--
|
||||
}
|
||||
// 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)
|
||||
|
|
|
|||
|
|
@ -64,6 +64,11 @@ 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>()
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@ 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
|
||||
|
|
@ -16,11 +18,15 @@ 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 logcat.LogPriority
|
||||
import exh.log.xLogE
|
||||
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
|
||||
|
|
@ -36,7 +42,17 @@ class ExtensionInstallService : Service() {
|
|||
setContentTitle(stringResource(MR.strings.ext_install_service_notif))
|
||||
setProgress(100, 100, true)
|
||||
}.build()
|
||||
startForeground(Notifications.ID_EXTENSION_INSTALLER, notification)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
|
|
@ -53,7 +69,7 @@ class ExtensionInstallService : Service() {
|
|||
BasePreferences.ExtensionInstaller.PACKAGEINSTALLER -> PackageInstallerInstaller(this)
|
||||
BasePreferences.ExtensionInstaller.SHIZUKU -> ShizukuInstaller(this)
|
||||
else -> {
|
||||
logcat(LogPriority.ERROR) { "Not implemented for installer $installerUsed" }
|
||||
xLogE("Not implemented for installer $installerUsed")
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,66 +1,52 @@
|
|||
package eu.kanade.tachiyomi.extension.util
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.net.toUri
|
||||
import eu.kanade.domain.base.BasePreferences
|
||||
import eu.kanade.tachiyomi.extension.ExtensionManager
|
||||
import eu.kanade.tachiyomi.extension.installer.Installer
|
||||
import eu.kanade.tachiyomi.extension.model.Extension
|
||||
import eu.kanade.tachiyomi.extension.model.InstallStep
|
||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||
import eu.kanade.tachiyomi.util.storage.getUriCompat
|
||||
import eu.kanade.tachiyomi.util.system.isPackageInstalled
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.transformWhile
|
||||
import kotlinx.coroutines.launch
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.common.util.lang.withUIContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.io.File
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* The installer which installs, updates and uninstalls the extensions.
|
||||
*
|
||||
* @param context The application context.
|
||||
*/
|
||||
internal class ExtensionInstaller(private val context: Context) {
|
||||
|
||||
/**
|
||||
* The system's download manager
|
||||
*/
|
||||
private val downloadManager = context.getSystemService<DownloadManager>()!!
|
||||
|
||||
/**
|
||||
* The broadcast receiver which listens to download completion events.
|
||||
*/
|
||||
private val downloadReceiver = DownloadCompletionReceiver()
|
||||
|
||||
/**
|
||||
* The currently requested downloads, with the package name (unique id) as key, and the id
|
||||
* returned by the download manager.
|
||||
*/
|
||||
private val activeDownloads = hashMapOf<String, Long>()
|
||||
|
||||
private val downloadsStateFlows = hashMapOf<Long, MutableStateFlow<InstallStep>>()
|
||||
internal class ExtensionInstaller(
|
||||
private val context: Context,
|
||||
) {
|
||||
|
||||
// KMK -->
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val activeJobs = ConcurrentHashMap<String, Job>()
|
||||
private val activeSteps = ConcurrentHashMap<Long, MutableStateFlow<InstallStep>>()
|
||||
// KMK <--
|
||||
private val extensionInstaller = Injekt.get<BasePreferences>().extensionInstaller()
|
||||
|
||||
private val httpClient: OkHttpClient = Injekt.get<NetworkHelper>().client
|
||||
|
||||
/**
|
||||
* Adds the given extension to the downloads queue and returns an observable containing its
|
||||
* step in the installation process.
|
||||
|
|
@ -71,130 +57,97 @@ internal class ExtensionInstaller(private val context: Context) {
|
|||
fun downloadAndInstall(url: String, extension: Extension): Flow<InstallStep> {
|
||||
val pkgName = extension.pkgName +
|
||||
// KMK -->
|
||||
":${extension.signatureHash}"
|
||||
"_${extension.signatureHash}"
|
||||
val downloadId = pkgName.toDownloadId()
|
||||
// KMK <--
|
||||
cancelInstall(pkgName)
|
||||
|
||||
val oldDownload = activeDownloads[pkgName]
|
||||
if (oldDownload != null) {
|
||||
deleteDownload(pkgName)
|
||||
}
|
||||
val step = MutableStateFlow(InstallStep.Pending)
|
||||
activeSteps[downloadId] = step
|
||||
|
||||
// Register the receiver after removing (and unregistering) the previous download
|
||||
downloadReceiver.register()
|
||||
val job = scope.launch {
|
||||
val tmpFile = File(context.cacheDir, "extension_$pkgName.apk")
|
||||
try {
|
||||
step.value = InstallStep.Downloading
|
||||
val request = Request.Builder().url(url).build()
|
||||
httpClient.newCall(request).execute()
|
||||
// KMK -->
|
||||
.use { response ->
|
||||
// KMK <--
|
||||
|
||||
val downloadUri = url.toUri()
|
||||
val request = DownloadManager.Request(downloadUri)
|
||||
.setTitle(extension.name)
|
||||
.setMimeType(APK_MIME)
|
||||
.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, downloadUri.lastPathSegment)
|
||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||
if (!response.isSuccessful) {
|
||||
throw Exception("Failed to download extension")
|
||||
}
|
||||
// KMK -->
|
||||
tmpFile.outputStream().use { output ->
|
||||
response.body.byteStream().use { input ->
|
||||
// KMK <--
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val id = downloadManager.enqueue(request)
|
||||
activeDownloads[pkgName] = id
|
||||
|
||||
val downloadStateFlow = MutableStateFlow(InstallStep.Pending)
|
||||
downloadsStateFlows[id] = downloadStateFlow
|
||||
|
||||
// Poll download status
|
||||
val pollStatusFlow = downloadStatusFlow(id).mapNotNull { downloadStatus ->
|
||||
// Map to our model
|
||||
when (downloadStatus) {
|
||||
DownloadManager.STATUS_PENDING -> InstallStep.Pending
|
||||
DownloadManager.STATUS_RUNNING -> InstallStep.Downloading
|
||||
else -> null
|
||||
step.value = InstallStep.Installing
|
||||
installApk(downloadId, tmpFile)
|
||||
} catch (e: Exception) {
|
||||
if (e is InterruptedException) {
|
||||
// Canceled
|
||||
} else {
|
||||
logcat(LogPriority.ERROR, e)
|
||||
step.value = InstallStep.Error
|
||||
}
|
||||
// KMK -->
|
||||
tmpFile.delete()
|
||||
// KMK <--
|
||||
}
|
||||
}
|
||||
|
||||
return merge(downloadStateFlow, pollStatusFlow).transformWhile {
|
||||
emit(it)
|
||||
// Stop when the application is installed or errors
|
||||
!it.isCompleted()
|
||||
}.onCompletion {
|
||||
// Always notify on main thread
|
||||
withUIContext {
|
||||
// Always remove the download when unsubscribed
|
||||
deleteDownload(pkgName)
|
||||
activeJobs[pkgName] = job
|
||||
|
||||
return step.asStateFlow()
|
||||
.onCompletion {
|
||||
activeJobs.remove(pkgName)
|
||||
activeSteps.remove(downloadId)
|
||||
job.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a flow that polls the given download id for its status every second, as the
|
||||
* manager doesn't have any notification system. It'll stop once the download finishes.
|
||||
*
|
||||
* @param id The id of the download to poll.
|
||||
*/
|
||||
private fun downloadStatusFlow(id: Long): Flow<Int> = flow {
|
||||
val query = DownloadManager.Query().setFilterById(id)
|
||||
while (true) {
|
||||
// Get the current download status
|
||||
val downloadStatus = downloadManager.query(query).use { cursor ->
|
||||
if (!cursor.moveToFirst()) return@flow
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
|
||||
}
|
||||
|
||||
emit(downloadStatus)
|
||||
|
||||
// Stop polling when the download fails or finishes
|
||||
if (
|
||||
downloadStatus == DownloadManager.STATUS_SUCCESSFUL ||
|
||||
downloadStatus == DownloadManager.STATUS_FAILED
|
||||
) {
|
||||
return@flow
|
||||
}
|
||||
|
||||
delay(1.seconds)
|
||||
}
|
||||
}
|
||||
// Ignore duplicate results
|
||||
.distinctUntilChanged()
|
||||
|
||||
/**
|
||||
* Starts an intent to install the extension at the given uri.
|
||||
*
|
||||
* @param uri The uri of the extension to install.
|
||||
* @param tempFile The file of the extension to install. Delete after use.
|
||||
*/
|
||||
fun installApk(downloadId: Long, uri: Uri) {
|
||||
private fun installApk(downloadId: Long, tempFile: File) {
|
||||
when (val installer = extensionInstaller.get()) {
|
||||
BasePreferences.ExtensionInstaller.LEGACY -> {
|
||||
val intent = Intent(context, ExtensionInstallActivity::class.java)
|
||||
.setDataAndType(uri, APK_MIME)
|
||||
.setDataAndType(tempFile.getUriCompat(context), APK_MIME)
|
||||
.putExtra(EXTRA_DOWNLOAD_ID, downloadId)
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
|
||||
context.startActivity(intent)
|
||||
}
|
||||
BasePreferences.ExtensionInstaller.PRIVATE -> {
|
||||
val extensionManager = Injekt.get<ExtensionManager>()
|
||||
val tempFile = File(context.cacheDir, "temp_$downloadId")
|
||||
|
||||
if (tempFile.exists() && !tempFile.delete()) {
|
||||
// Unlikely but just in case
|
||||
extensionManager.updateInstallStep(downloadId, InstallStep.Error)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
tempFile.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
|
||||
if (ExtensionLoader.installPrivateExtensionFile(context, tempFile)) {
|
||||
extensionManager.updateInstallStep(downloadId, InstallStep.Installed)
|
||||
updateInstallStep(downloadId, InstallStep.Installed)
|
||||
} else {
|
||||
extensionManager.updateInstallStep(downloadId, InstallStep.Error)
|
||||
updateInstallStep(downloadId, InstallStep.Error)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR, e) { "Failed to read downloaded extension file." }
|
||||
extensionManager.updateInstallStep(downloadId, InstallStep.Error)
|
||||
updateInstallStep(downloadId, InstallStep.Error)
|
||||
}
|
||||
|
||||
tempFile.delete()
|
||||
}
|
||||
else -> {
|
||||
val intent = ExtensionInstallService.getIntent(context, downloadId, uri, installer)
|
||||
val intent = ExtensionInstallService.getIntent(
|
||||
context,
|
||||
downloadId,
|
||||
tempFile.getUriCompat(context),
|
||||
installer,
|
||||
)
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
}
|
||||
}
|
||||
|
|
@ -204,9 +157,8 @@ internal class ExtensionInstaller(private val context: Context) {
|
|||
* Cancels extension install and remove from download manager and installer.
|
||||
*/
|
||||
fun cancelInstall(pkgName: String) {
|
||||
val downloadId = activeDownloads.remove(pkgName) ?: return
|
||||
downloadManager.remove(downloadId)
|
||||
Installer.cancelInstallQueue(context, downloadId)
|
||||
activeJobs.remove(pkgName)?.cancel()
|
||||
Installer.cancelInstallQueue(context, /* KMK --> */ pkgName.toDownloadId() /* KMK <-- */)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -233,91 +185,16 @@ internal class ExtensionInstaller(private val context: Context) {
|
|||
* @param step New install step.
|
||||
*/
|
||||
fun updateInstallStep(downloadId: Long, step: InstallStep) {
|
||||
downloadsStateFlows[downloadId]?.let { it.value = step }
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the download for the given package name.
|
||||
*
|
||||
* @param pkgName The package name of the download to delete together with its signature.
|
||||
*/
|
||||
private fun deleteDownload(pkgName: String) {
|
||||
val downloadId = activeDownloads.remove(pkgName)
|
||||
if (downloadId != null) {
|
||||
downloadManager.remove(downloadId)
|
||||
downloadsStateFlows.remove(downloadId)
|
||||
}
|
||||
if (activeDownloads.isEmpty()) {
|
||||
downloadReceiver.unregister()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Receiver that listens to download status events.
|
||||
*/
|
||||
private inner class DownloadCompletionReceiver : BroadcastReceiver() {
|
||||
|
||||
/**
|
||||
* Whether this receiver is currently registered.
|
||||
*/
|
||||
private var isRegistered = false
|
||||
|
||||
/**
|
||||
* Registers this receiver if it's not already.
|
||||
*/
|
||||
fun register() {
|
||||
if (isRegistered) return
|
||||
isRegistered = true
|
||||
|
||||
val filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
|
||||
ContextCompat.registerReceiver(context, this, filter, ContextCompat.RECEIVER_EXPORTED)
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters this receiver if it's not already.
|
||||
*/
|
||||
fun unregister() {
|
||||
if (!isRegistered) return
|
||||
isRegistered = false
|
||||
|
||||
context.unregisterReceiver(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a download event is received. It looks for the download in the current active
|
||||
* downloads and notifies its installation step.
|
||||
*/
|
||||
override fun onReceive(context: Context, intent: Intent?) {
|
||||
val id = intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0) ?: return
|
||||
|
||||
// Avoid events for downloads we didn't request
|
||||
if (id !in activeDownloads.values) return
|
||||
|
||||
val uri = downloadManager.getUriForDownloadedFile(id)
|
||||
|
||||
// Set next installation step
|
||||
if (uri == null) {
|
||||
logcat(LogPriority.ERROR) { "Couldn't locate downloaded APK" }
|
||||
updateInstallStep(id, InstallStep.Error)
|
||||
return
|
||||
}
|
||||
|
||||
val query = DownloadManager.Query().setFilterById(id)
|
||||
downloadManager.query(query).use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val localUri = cursor.getString(
|
||||
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_URI),
|
||||
).removePrefix(FILE_SCHEME)
|
||||
|
||||
installApk(id, File(localUri).getUriCompat(context))
|
||||
}
|
||||
}
|
||||
}
|
||||
activeSteps[downloadId]?.let { it.value = step }
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val APK_MIME = "application/vnd.android.package-archive"
|
||||
const val EXTRA_DOWNLOAD_ID = "ExtensionInstaller.extra.DOWNLOAD_ID"
|
||||
const val FILE_SCHEME = "file://"
|
||||
|
||||
// KMK -->
|
||||
/** Convert packageName to download ID avoiding negative number */
|
||||
private fun String.toDownloadId(): Long = hashCode().toLong() and 0xFFFFFFFFL
|
||||
// KMK <--
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ import kotlinx.coroutines.async
|
|||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import logcat.LogPriority
|
||||
import mihon.domain.extensionrepo.interactor.GetExtensionRepo
|
||||
import mihon.domain.extensionrepo.model.ExtensionRepo
|
||||
import mihon.domain.extension.interactor.GetExtensionStores
|
||||
import mihon.domain.extension.model.ExtensionStore
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.File
|
||||
|
|
@ -46,7 +46,7 @@ internal object ExtensionLoader {
|
|||
private val trustExtension: TrustExtension by injectLazy()
|
||||
|
||||
// KMK -->
|
||||
private val getExtensionRepo: GetExtensionRepo by injectLazy()
|
||||
private val getExtensionStores: GetExtensionStores by injectLazy()
|
||||
// KMK <--
|
||||
|
||||
private val loadNsfwSource by lazy {
|
||||
|
|
@ -169,7 +169,7 @@ internal object ExtensionLoader {
|
|||
// Load each extension concurrently and wait for completion
|
||||
return runBlocking {
|
||||
// KMK -->
|
||||
val extRepos = getExtensionRepo.getAll()
|
||||
val extStores = getExtensionStores.get()
|
||||
// KMK <--
|
||||
val deferred = extPkgs.map {
|
||||
async {
|
||||
|
|
@ -177,7 +177,7 @@ internal object ExtensionLoader {
|
|||
context,
|
||||
it,
|
||||
// KMK -->
|
||||
extRepos,
|
||||
extStores,
|
||||
// KMK <--
|
||||
)
|
||||
}
|
||||
|
|
@ -228,7 +228,7 @@ internal object ExtensionLoader {
|
|||
isShared = true,
|
||||
)
|
||||
}
|
||||
} catch (error: PackageManager.NameNotFoundException) {
|
||||
} catch (_: PackageManager.NameNotFoundException) {
|
||||
null
|
||||
}
|
||||
|
||||
|
|
@ -245,11 +245,11 @@ internal object ExtensionLoader {
|
|||
context: Context,
|
||||
extensionInfo: ExtensionInfo,
|
||||
// KMK -->
|
||||
extRepos: List<ExtensionRepo>? = null,
|
||||
extStores: List<ExtensionStore>? = null,
|
||||
// KMK <--
|
||||
): LoadResult {
|
||||
// KMK -->
|
||||
val repos = extRepos ?: getExtensionRepo.getAll()
|
||||
val stores = extStores ?: getExtensionStores.get()
|
||||
// KMK <--
|
||||
val pkgManager = context.packageManager
|
||||
val pkgInfo = extensionInfo.packageInfo
|
||||
|
|
@ -288,10 +288,10 @@ internal object ExtensionLoader {
|
|||
libVersion,
|
||||
signatures.last(),
|
||||
// KMK -->
|
||||
repoName = repos.firstOrNull { repo ->
|
||||
signatures.all { it == repo.signingKeyFingerprint }
|
||||
}?.let { repo ->
|
||||
repo.shortName.takeIf { !it.isNullOrBlank() } ?: repo.name
|
||||
storeName = stores.firstOrNull { store ->
|
||||
signatures.all { it == store.signingKey }
|
||||
}?.let { store ->
|
||||
store.badgeLabel.takeIf(String::isNotBlank) ?: store.name
|
||||
},
|
||||
// KMK <--
|
||||
)
|
||||
|
|
@ -358,10 +358,10 @@ internal object ExtensionLoader {
|
|||
isShared = extensionInfo.isShared,
|
||||
// KMK -->
|
||||
signatureHash = signatures.last(),
|
||||
repoName = repos.firstOrNull { repo ->
|
||||
signatures.all { it == repo.signingKeyFingerprint }
|
||||
}?.let { repo ->
|
||||
repo.shortName.takeIf { !it.isNullOrBlank() } ?: repo.name
|
||||
storeName = stores.firstOrNull { store ->
|
||||
signatures.all { it == store.signingKey }
|
||||
}?.let { store ->
|
||||
store.badgeLabel.takeIf(String::isNotBlank) ?: store.name
|
||||
},
|
||||
// KMK <--
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,9 +13,7 @@ import eu.kanade.tachiyomi.source.online.all.MangaDex
|
|||
import eu.kanade.tachiyomi.source.online.all.MergedSource
|
||||
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 exh.log.xLogD
|
||||
import exh.source.BlacklistedSources
|
||||
import exh.source.DelegatedHttpSource
|
||||
|
|
@ -24,10 +22,8 @@ import exh.source.EIGHTMUSES_SOURCE_ID
|
|||
import exh.source.EXHENTAI_EXT_SOURCES
|
||||
import exh.source.EnhancedHttpSource
|
||||
import exh.source.ExhPreferences
|
||||
import exh.source.HBROWSE_SOURCE_ID
|
||||
import exh.source.MERGED_SOURCE_ID
|
||||
import exh.source.PURURIN_SOURCE_ID
|
||||
import exh.source.TSUMINO_SOURCE_ID
|
||||
import exh.source.handleSourceLibrary
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -282,12 +278,6 @@ class AndroidSourceManager(
|
|||
"eu.kanade.tachiyomi.extension.en.pururin.Pururin",
|
||||
Pururin::class,
|
||||
),
|
||||
DelegatedSource(
|
||||
"Tsumino",
|
||||
TSUMINO_SOURCE_ID,
|
||||
"eu.kanade.tachiyomi.extension.en.tsumino.Tsumino",
|
||||
Tsumino::class,
|
||||
),
|
||||
DelegatedSource(
|
||||
"MangaDex",
|
||||
fillInSourceId,
|
||||
|
|
@ -295,12 +285,6 @@ class AndroidSourceManager(
|
|||
MangaDex::class,
|
||||
true,
|
||||
),
|
||||
DelegatedSource(
|
||||
"HBrowse",
|
||||
HBROWSE_SOURCE_ID,
|
||||
"eu.kanade.tachiyomi.extension.en.hbrowse.HBrowse",
|
||||
HBrowse::class,
|
||||
),
|
||||
DelegatedSource(
|
||||
"8Muses",
|
||||
EIGHTMUSES_SOURCE_ID,
|
||||
|
|
|
|||
|
|
@ -1,95 +0,0 @@
|
|||
package eu.kanade.tachiyomi.source.online.english
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import eu.kanade.tachiyomi.network.awaitSuccess
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.source.online.MetadataSource
|
||||
import eu.kanade.tachiyomi.source.online.NamespaceSource
|
||||
import eu.kanade.tachiyomi.source.online.UrlImportableSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import exh.metadata.metadata.HBrowseSearchMetadata
|
||||
import exh.metadata.metadata.base.RaisedTag
|
||||
import exh.source.DelegatedHttpSource
|
||||
import exh.util.urlImportFetchSearchManga
|
||||
import exh.util.urlImportFetchSearchMangaSuspend
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
|
||||
class HBrowse(delegate: HttpSource, val context: Context) :
|
||||
DelegatedHttpSource(delegate),
|
||||
MetadataSource<HBrowseSearchMetadata, Document>,
|
||||
UrlImportableSource,
|
||||
NamespaceSource {
|
||||
override val metaClass = HBrowseSearchMetadata::class
|
||||
override fun newMetaInstance() = HBrowseSearchMetadata()
|
||||
override val lang = "en"
|
||||
|
||||
// Support direct URL importing
|
||||
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getSearchManga"))
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList) =
|
||||
urlImportFetchSearchManga(context, query) {
|
||||
@Suppress("DEPRECATION")
|
||||
super<DelegatedHttpSource>.fetchSearchManga(page, query, filters)
|
||||
}
|
||||
|
||||
override suspend fun getSearchManga(page: Int, query: String, filters: FilterList): MangasPage {
|
||||
return urlImportFetchSearchMangaSuspend(context, query) {
|
||||
super<DelegatedHttpSource>.getSearchManga(page, query, filters)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getMangaDetails(manga: SManga): SManga {
|
||||
val response = client.newCall(mangaDetailsRequest(manga)).awaitSuccess()
|
||||
return parseToManga(manga, response.asJsoup())
|
||||
}
|
||||
|
||||
override suspend fun parseIntoMetadata(metadata: HBrowseSearchMetadata, input: Document) {
|
||||
val tables = parseIntoTables(input)
|
||||
with(metadata) {
|
||||
hbUrl = input.location().removePrefix("$baseUrl/thumbnails")
|
||||
|
||||
hbId = hbUrl!!.removePrefix("/").substringBefore("/").toLong()
|
||||
|
||||
tags.clear()
|
||||
((tables[""] ?: error("")) + (tables["categories"] ?: error(""))).forEach { (k, v) ->
|
||||
when (val lowercaseNs = k.lowercase()) {
|
||||
"title" -> title = v.text()
|
||||
"length" -> length = v.text().substringBefore(" ").toInt()
|
||||
else -> {
|
||||
v.getElementsByTag("a").forEach {
|
||||
tags += RaisedTag(
|
||||
lowercaseNs,
|
||||
it.text(),
|
||||
HBrowseSearchMetadata.TAG_TYPE_DEFAULT,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseIntoTables(doc: Document): Map<String, Map<String, Element>> {
|
||||
return doc.select("#main > .listTable").associate { ele ->
|
||||
val tableName = ele.previousElementSibling()?.text()?.lowercase().orEmpty()
|
||||
tableName to ele.select("tr")
|
||||
.filter { element -> element.childrenSize() > 1 }
|
||||
.associate {
|
||||
it.child(0).text() to it.child(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val matchingHosts = listOf(
|
||||
"www.hbrowse.com",
|
||||
"hbrowse.com",
|
||||
)
|
||||
|
||||
override suspend fun mapUrlToMangaUrl(uri: Uri): String? {
|
||||
return uri.pathSegments.firstOrNull()?.let { "/$it/c00001/" }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
package eu.kanade.tachiyomi.source.online.english
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import eu.kanade.tachiyomi.network.awaitSuccess
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.source.online.MetadataSource
|
||||
import eu.kanade.tachiyomi.source.online.NamespaceSource
|
||||
import eu.kanade.tachiyomi.source.online.UrlImportableSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import exh.metadata.metadata.RaisedSearchMetadata.Companion.TAG_TYPE_VIRTUAL
|
||||
import exh.metadata.metadata.TsuminoSearchMetadata
|
||||
import exh.metadata.metadata.TsuminoSearchMetadata.Companion.TAG_TYPE_DEFAULT
|
||||
import exh.metadata.metadata.base.RaisedTag
|
||||
import exh.source.DelegatedHttpSource
|
||||
import exh.util.dropBlank
|
||||
import exh.util.trimAll
|
||||
import exh.util.urlImportFetchSearchManga
|
||||
import exh.util.urlImportFetchSearchMangaSuspend
|
||||
import org.jsoup.nodes.Document
|
||||
import rx.Observable
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
class Tsumino(delegate: HttpSource, val context: Context) :
|
||||
DelegatedHttpSource(delegate),
|
||||
MetadataSource<TsuminoSearchMetadata, Document>,
|
||||
UrlImportableSource,
|
||||
NamespaceSource {
|
||||
override val metaClass = TsuminoSearchMetadata::class
|
||||
override fun newMetaInstance() = TsuminoSearchMetadata()
|
||||
override val lang = "en"
|
||||
|
||||
// Support direct URL importing
|
||||
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getSearchManga"))
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> =
|
||||
urlImportFetchSearchManga(context, query) {
|
||||
@Suppress("DEPRECATION")
|
||||
super<DelegatedHttpSource>.fetchSearchManga(page, query, filters)
|
||||
}
|
||||
|
||||
override suspend fun getSearchManga(page: Int, query: String, filters: FilterList): MangasPage {
|
||||
return urlImportFetchSearchMangaSuspend(context, query) {
|
||||
super<DelegatedHttpSource>.getSearchManga(page, query, filters)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun mapUrlToMangaUrl(uri: Uri): String? {
|
||||
val lcFirstPathSegment = uri.pathSegments.firstOrNull()?.lowercase(Locale.ROOT) ?: return null
|
||||
if (lcFirstPathSegment != "read" && lcFirstPathSegment != "book" && lcFirstPathSegment != "entry") {
|
||||
return null
|
||||
}
|
||||
return "https://tsumino.com/Book/Info/${uri.lastPathSegment}"
|
||||
}
|
||||
|
||||
override suspend fun getMangaDetails(manga: SManga): SManga {
|
||||
val response = client.newCall(mangaDetailsRequest(manga)).awaitSuccess()
|
||||
return parseToManga(manga, response.asJsoup())
|
||||
}
|
||||
|
||||
override suspend fun parseIntoMetadata(metadata: TsuminoSearchMetadata, input: Document) {
|
||||
with(metadata) {
|
||||
tmId = TsuminoSearchMetadata.tmIdFromUrl(input.location())!!.toInt()
|
||||
tags.clear()
|
||||
|
||||
input.select("meta[property=og:title]").firstOrNull()?.attr("content")?.let {
|
||||
title = it.trim()
|
||||
}
|
||||
|
||||
input.getElementById("Artist")?.children()?.first()?.attr("data-define")?.trim()?.let { artistString ->
|
||||
artistString.split("|").trimAll().dropBlank().forEach {
|
||||
tags.add(RaisedTag("artist", it, TAG_TYPE_DEFAULT))
|
||||
}
|
||||
tags.add(RaisedTag("artist", artistString, TAG_TYPE_VIRTUAL))
|
||||
artist = artistString
|
||||
}
|
||||
|
||||
input.getElementById("Uploader")?.children()?.first()?.text()?.trim()?.let {
|
||||
uploader = it
|
||||
}
|
||||
|
||||
input.getElementById("Uploaded")?.text()?.let {
|
||||
uploadDate = TM_DATE_FORMAT.parse(it.trim())!!.time
|
||||
}
|
||||
|
||||
input.getElementById("Pages")?.text()?.let {
|
||||
length = it.trim().toIntOrNull()
|
||||
}
|
||||
|
||||
input.getElementById("Rating")?.text()?.let {
|
||||
ratingString = it.trim()
|
||||
val ratingString = ratingString
|
||||
if (!ratingString.isNullOrBlank()) {
|
||||
averageRating = RATING_FLOAT_REGEX.find(ratingString)?.groups?.get(1)?.value?.toFloatOrNull()
|
||||
userRatings = RATING_USERS_REGEX.find(ratingString)?.groups?.get(1)?.value?.toLongOrNull()
|
||||
favorites = RATING_FAVORITES_REGEX.find(ratingString)?.groups?.get(1)?.value?.toLongOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
input.getElementById("Category")?.children()?.first()?.attr("data-define")?.let {
|
||||
category = it.trim()
|
||||
tags.add(RaisedTag("genre", it, TAG_TYPE_VIRTUAL))
|
||||
}
|
||||
|
||||
input.getElementById("Collection")?.children()?.first()?.attr("data-define")?.let {
|
||||
collection = it.trim()
|
||||
tags.add(RaisedTag("collection", it, TAG_TYPE_DEFAULT))
|
||||
}
|
||||
|
||||
input.getElementById("Group")?.children()?.first()?.attr("data-define")?.let {
|
||||
group = it.trim()
|
||||
tags.add(RaisedTag("group", it, TAG_TYPE_DEFAULT))
|
||||
}
|
||||
|
||||
parody = input.getElementById("Parody")?.children()?.map {
|
||||
val entry = it.attr("data-define").trim()
|
||||
tags.add(RaisedTag("parody", entry, TAG_TYPE_DEFAULT))
|
||||
entry
|
||||
}.orEmpty()
|
||||
|
||||
character = input.getElementById("Character")?.children()?.map {
|
||||
val entry = it.attr("data-define").trim()
|
||||
tags.add(RaisedTag("character", entry, TAG_TYPE_DEFAULT))
|
||||
entry
|
||||
}.orEmpty()
|
||||
|
||||
input.getElementById("Tag")?.children()?.let { tagElements ->
|
||||
tags.addAll(
|
||||
tagElements.map {
|
||||
RaisedTag("tags", it.attr("data-define").trim(), TAG_TYPE_DEFAULT)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val matchingHosts = listOf(
|
||||
"www.tsumino.com",
|
||||
"tsumino.com",
|
||||
)
|
||||
|
||||
companion object {
|
||||
val TM_DATE_FORMAT = SimpleDateFormat("yyyy MMM dd", Locale.US)
|
||||
val RATING_FLOAT_REGEX = "([0-9].*) \\(".toRegex()
|
||||
val RATING_USERS_REGEX = "\\(([0-9].*) users".toRegex()
|
||||
val RATING_FAVORITES_REGEX = "/ ([0-9].*) favs".toRegex()
|
||||
}
|
||||
}
|
||||
|
|
@ -59,7 +59,7 @@ class ExtensionsScreen(private val searchSource: String? = null) : Screen() {
|
|||
|
||||
/*
|
||||
* This will redo the searching for [searchSource] every times the screen is launched, for example when
|
||||
* back from the [ExtensionFilterScreen] or from the [ExtensionReposScreen].
|
||||
* back from the [ExtensionFilterScreen] or from the [ExtensionStoresScreen].
|
||||
* Not really desired but let's accept it.
|
||||
*/
|
||||
if (!searchSource.isNullOrBlank()) onChangeSearchQuery(searchSource)
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.launchIn
|
|||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.takeWhile
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import tachiyomi.core.common.util.lang.launchIO
|
||||
|
|
@ -53,7 +54,7 @@ class ExtensionsScreenModel(
|
|||
map[
|
||||
it.pkgName +
|
||||
// KMK -->
|
||||
":${it.signatureHash}",
|
||||
"_${it.signatureHash}",
|
||||
// KMK <--
|
||||
] ?: InstallStep.Idle,
|
||||
)
|
||||
|
|
@ -207,7 +208,7 @@ class ExtensionsScreenModel(
|
|||
it + Pair(
|
||||
extension.pkgName +
|
||||
// KMK -->
|
||||
":${extension.signatureHash}",
|
||||
"_${extension.signatureHash}",
|
||||
// KMK <--
|
||||
installStep,
|
||||
)
|
||||
|
|
@ -219,7 +220,7 @@ class ExtensionsScreenModel(
|
|||
it - (
|
||||
extension.pkgName +
|
||||
// KMK -->
|
||||
":${extension.signatureHash}"
|
||||
"_${extension.signatureHash}"
|
||||
// KMK <--
|
||||
)
|
||||
}
|
||||
|
|
@ -228,6 +229,7 @@ class ExtensionsScreenModel(
|
|||
private suspend fun Flow<InstallStep>.collectToInstallUpdate(extension: Extension) =
|
||||
this
|
||||
.onEach { installStep -> addDownloadState(extension, installStep) }
|
||||
.takeWhile { installStep -> installStep != InstallStep.Installed }
|
||||
.onCompletion { removeDownloadState(extension) }
|
||||
.collect()
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import cafe.adriel.voyager.navigator.currentOrThrow
|
|||
import eu.kanade.presentation.browse.ExtensionScreen
|
||||
import eu.kanade.presentation.components.AppBar
|
||||
import eu.kanade.presentation.components.TabContent
|
||||
import eu.kanade.presentation.more.settings.screen.browse.ExtensionReposScreen
|
||||
import eu.kanade.presentation.more.settings.screen.browse.ExtensionStoresScreen
|
||||
import eu.kanade.tachiyomi.extension.model.Extension
|
||||
import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionDetailsScreen
|
||||
import eu.kanade.tachiyomi.ui.webview.WebViewScreen
|
||||
|
|
@ -62,8 +62,8 @@ fun extensionsTab(
|
|||
onClick = { navigator.push(ExtensionFilterScreen()) },
|
||||
),
|
||||
AppBar.OverflowAction(
|
||||
title = stringResource(MR.strings.label_extension_repos),
|
||||
onClick = { navigator.push(ExtensionReposScreen()) },
|
||||
title = stringResource(MR.strings.extensionStores),
|
||||
onClick = { navigator.push(ExtensionStoresScreen()) },
|
||||
),
|
||||
),
|
||||
content = { contentPadding, _ ->
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import androidx.compose.runtime.setValue
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.fragment.app.FragmentContainerView
|
||||
import androidx.fragment.app.FragmentManager
|
||||
|
|
@ -182,7 +181,9 @@ class SourcePreferencesFragment : PreferenceFragmentCompat() {
|
|||
|
||||
fun getInstance(sourceId: Long): SourcePreferencesFragment {
|
||||
return SourcePreferencesFragment().apply {
|
||||
arguments = bundleOf(SOURCE_ID to sourceId)
|
||||
arguments = Bundle().apply {
|
||||
putLong(SOURCE_ID, sourceId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -128,11 +128,9 @@ data class MigrateMangaScreen(
|
|||
// KMK -->
|
||||
bottomBar = {
|
||||
MigrateMangaBottomBar(
|
||||
selected = state.selection,
|
||||
selectionMode = state.selectionMode,
|
||||
onMultiMigrateClicked = {
|
||||
val selection = state.selection
|
||||
.map { it.manga.id }
|
||||
navigator.push(MigrationConfigScreen(selection))
|
||||
navigator.push(MigrationConfigScreen(state.selection))
|
||||
},
|
||||
enableScrollToTop = enableScrollToTop,
|
||||
enableScrollToBottom = enableScrollToBottom,
|
||||
|
|
@ -187,7 +185,7 @@ data class MigrateMangaScreen(
|
|||
contentPadding: PaddingValues,
|
||||
state: MigrateMangaScreenModel.State,
|
||||
// KMK -->
|
||||
onMangaSelected: (MigrateMangaItem, Boolean, Boolean, Boolean) -> Unit,
|
||||
onMangaSelected: (Manga, Boolean, Boolean) -> Unit,
|
||||
// KMK <--
|
||||
onClickItem: (Manga) -> Unit,
|
||||
onClickCover: (Manga) -> Unit,
|
||||
|
|
@ -197,20 +195,23 @@ data class MigrateMangaScreen(
|
|||
contentPadding = contentPadding,
|
||||
) {
|
||||
items(items = state.titles) { manga ->
|
||||
// KMK -->
|
||||
val isSelected = manga.id in state.selection
|
||||
// KMK <--
|
||||
MigrateMangaItem(
|
||||
manga = manga.manga,
|
||||
isSelected = manga.selected,
|
||||
manga = manga,
|
||||
isSelected = isSelected,
|
||||
onClickItem = {
|
||||
// KMK -->
|
||||
when {
|
||||
state.selectionMode -> onMangaSelected(manga, !manga.selected, true, false)
|
||||
state.selectionMode -> onMangaSelected(manga, !isSelected, false)
|
||||
// KMK <--
|
||||
else -> onClickItem(it)
|
||||
}
|
||||
},
|
||||
onClickCover = onClickCover,
|
||||
// KMK -->
|
||||
onLongClick = { onMangaSelected(manga, !manga.selected, true, true) },
|
||||
onLongClick = { onMangaSelected(manga, !isSelected, true) },
|
||||
modifier = Modifier.animateItemFastScroll(),
|
||||
// KMK <--
|
||||
)
|
||||
|
|
@ -293,7 +294,7 @@ data class MigrateMangaScreen(
|
|||
@Composable
|
||||
private fun MigrateMangaBottomBar(
|
||||
modifier: Modifier = Modifier,
|
||||
selected: List<MigrateMangaItem>,
|
||||
selectionMode: Boolean,
|
||||
onMultiMigrateClicked: () -> Unit,
|
||||
enableScrollToTop: Boolean,
|
||||
enableScrollToBottom: Boolean,
|
||||
|
|
@ -302,7 +303,7 @@ data class MigrateMangaScreen(
|
|||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val animatedElevation by animateDpAsState(
|
||||
targetValue = if (selected.isNotEmpty()) 3.dp else 0.dp,
|
||||
targetValue = if (selectionMode) 3.dp else 0.dp,
|
||||
label = "elevation",
|
||||
)
|
||||
Surface(
|
||||
|
|
@ -350,7 +351,7 @@ data class MigrateMangaScreen(
|
|||
toConfirm = confirm[1],
|
||||
onLongClick = { onLongClickItem(1) },
|
||||
onClick = onMultiMigrateClicked,
|
||||
enabled = selected.isNotEmpty(),
|
||||
enabled = selectionMode,
|
||||
)
|
||||
Button(
|
||||
title = stringResource(KMR.strings.action_scroll_to_bottom),
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ package eu.kanade.tachiyomi.ui.browse.migration.manga
|
|||
import androidx.compose.runtime.Immutable
|
||||
import cafe.adriel.voyager.core.model.StateScreenModel
|
||||
import cafe.adriel.voyager.core.model.screenModelScope
|
||||
import eu.kanade.core.util.addOrRemove
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
|
@ -17,6 +16,7 @@ import kotlinx.coroutines.flow.receiveAsFlow
|
|||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import logcat.LogPriority
|
||||
import mihon.core.common.utils.mutate
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import tachiyomi.domain.manga.interactor.GetFavorites
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
|
|
@ -36,7 +36,6 @@ class MigrateMangaScreenModel(
|
|||
// KMK -->
|
||||
// First and last selected index in list
|
||||
private val selectedPositions: Array<Int> = arrayOf(-1, -1)
|
||||
private val selectedMangaIds: HashSet<Long> = HashSet()
|
||||
// KMK <--
|
||||
|
||||
init {
|
||||
|
|
@ -50,120 +49,133 @@ class MigrateMangaScreenModel(
|
|||
logcat(LogPriority.ERROR, it)
|
||||
_events.send(MigrationMangaEvent.FailedFetchingFavorites)
|
||||
mutableState.update { state ->
|
||||
state.copy(titleList = persistentListOf())
|
||||
state.copy(
|
||||
titleList = persistentListOf(),
|
||||
// KMK -->
|
||||
selection = emptySet(),
|
||||
// KMK <--
|
||||
)
|
||||
}
|
||||
}
|
||||
// KMK -->
|
||||
.map { manga ->
|
||||
toMigrationMangaScreenItems(manga)
|
||||
}
|
||||
// KMK <--
|
||||
.map { manga ->
|
||||
manga
|
||||
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.manga.title })
|
||||
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.title })
|
||||
.toImmutableList()
|
||||
}
|
||||
.collectLatest { list ->
|
||||
mutableState.update { it.copy(titleList = list) }
|
||||
// KMK -->
|
||||
mutableState.update { state ->
|
||||
val titleIds = list.map { it.id }.toSet()
|
||||
val selection = state.selection.intersect(titleIds).toMutableSet()
|
||||
updateSelectedPositions(list, selection)
|
||||
state.copy(
|
||||
titleList = list,
|
||||
selection = selection,
|
||||
)
|
||||
}
|
||||
// KMK <--
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// KMK -->
|
||||
private fun toMigrationMangaScreenItems(mangas: List<Manga>): List<MigrateMangaItem> {
|
||||
return mangas.map { manga ->
|
||||
MigrateMangaItem(
|
||||
manga = manga,
|
||||
selected = manga.id in selectedMangaIds,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleSelection(
|
||||
item: MigrateMangaItem,
|
||||
item: Manga,
|
||||
// KMK -->
|
||||
selected: Boolean,
|
||||
userSelected: Boolean = false,
|
||||
fromLongPress: Boolean = false,
|
||||
) {
|
||||
mutableState.update { state ->
|
||||
val newItems = state.titles.toMutableList().apply {
|
||||
val selectedIndex = indexOfFirst { it.manga.id == item.manga.id }
|
||||
if (selectedIndex < 0) return@apply
|
||||
if (item.id in state.selection == selected) return@update state
|
||||
val selectedIndex = state.titles.indexOfFirst { it.id == item.id }
|
||||
if (selectedIndex < 0) return@update state
|
||||
|
||||
val selectedItem = get(selectedIndex)
|
||||
if (selectedItem.selected == selected) return@apply
|
||||
val selection = state.selection.mutate { list ->
|
||||
state.titles.run {
|
||||
val firstSelection = list.isEmpty()
|
||||
if (selected) list.add(item.id) else list.remove(item.id)
|
||||
|
||||
val firstSelection = none { it.selected }
|
||||
set(selectedIndex, selectedItem.copy(selected = selected))
|
||||
selectedMangaIds.addOrRemove(item.manga.id, selected)
|
||||
|
||||
if (selected && userSelected && fromLongPress) {
|
||||
if (firstSelection) {
|
||||
selectedPositions[0] = selectedIndex
|
||||
selectedPositions[1] = selectedIndex
|
||||
} else {
|
||||
// Try to select the items in-between when possible
|
||||
val range: IntRange
|
||||
if (selectedIndex < selectedPositions[0]) {
|
||||
range = selectedIndex + 1 until selectedPositions[0]
|
||||
if (selected && fromLongPress) {
|
||||
if (firstSelection) {
|
||||
selectedPositions[0] = selectedIndex
|
||||
} else if (selectedIndex > selectedPositions[1]) {
|
||||
range = (selectedPositions[1] + 1) until selectedIndex
|
||||
selectedPositions[1] = selectedIndex
|
||||
} else {
|
||||
// Just select itself
|
||||
range = IntRange.EMPTY
|
||||
}
|
||||
// Try to select the items in-between when possible
|
||||
val range: IntRange
|
||||
if (selectedIndex < selectedPositions[0]) {
|
||||
range = selectedIndex + 1 until selectedPositions[0]
|
||||
selectedPositions[0] = selectedIndex
|
||||
} else if (selectedIndex > selectedPositions[1]) {
|
||||
range = (selectedPositions[1] + 1) until selectedIndex
|
||||
selectedPositions[1] = selectedIndex
|
||||
} else {
|
||||
// Just select itself
|
||||
range = IntRange.EMPTY
|
||||
}
|
||||
|
||||
range.forEach {
|
||||
val inBetweenItem = get(it)
|
||||
if (!inBetweenItem.selected) {
|
||||
selectedMangaIds.add(inBetweenItem.manga.id)
|
||||
set(it, inBetweenItem.copy(selected = true))
|
||||
range.forEach {
|
||||
val inBetweenItem = get(it)
|
||||
if (inBetweenItem.id !in list) {
|
||||
list.add(inBetweenItem.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (userSelected && !fromLongPress) {
|
||||
if (!selected) {
|
||||
if (selectedIndex == selectedPositions[0]) {
|
||||
selectedPositions[0] = indexOfFirst { it.selected }
|
||||
} else if (selectedIndex == selectedPositions[1]) {
|
||||
selectedPositions[1] = indexOfLast { it.selected }
|
||||
}
|
||||
} else {
|
||||
if (selectedIndex < selectedPositions[0]) {
|
||||
selectedPositions[0] = selectedIndex
|
||||
} else if (selectedIndex > selectedPositions[1]) {
|
||||
selectedPositions[1] = selectedIndex
|
||||
} else if (!fromLongPress) {
|
||||
if (!selected) {
|
||||
if (selectedIndex == selectedPositions[0]) {
|
||||
selectedPositions[0] = indexOfFirst { it.id in list }
|
||||
} else if (selectedIndex == selectedPositions[1]) {
|
||||
selectedPositions[1] = indexOfLast { it.id in list }
|
||||
}
|
||||
} else {
|
||||
if (selectedIndex < selectedPositions[0]) {
|
||||
selectedPositions[0] = selectedIndex
|
||||
} else if (selectedIndex > selectedPositions[1]) {
|
||||
selectedPositions[1] = selectedIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
state.copy(titleList = newItems.toImmutableList())
|
||||
// KMK <--
|
||||
state.copy(selection = selection)
|
||||
}
|
||||
}
|
||||
|
||||
// KMK -->
|
||||
private fun updateSelectedPositions(titles: List<Manga>, selection: Set<Long>) {
|
||||
if (selection.isEmpty()) {
|
||||
selectedPositions[0] = -1
|
||||
selectedPositions[1] = -1
|
||||
return
|
||||
}
|
||||
|
||||
selectedPositions[0] = titles.indexOfFirst { it.id in selection }
|
||||
selectedPositions[1] = titles.indexOfLast { it.id in selection }
|
||||
}
|
||||
|
||||
fun toggleAllSelection(selected: Boolean = true) {
|
||||
mutableState.update { state ->
|
||||
val newItems = state.titles.map {
|
||||
selectedMangaIds.addOrRemove(it.manga.id, selected)
|
||||
it.copy(selected = selected)
|
||||
val selection = if (selected) {
|
||||
state.titles.mapTo(mutableSetOf()) { it.id }
|
||||
} else {
|
||||
emptySet()
|
||||
}
|
||||
selectedPositions[0] = -1
|
||||
selectedPositions[1] = -1
|
||||
state.copy(titleList = newItems.toImmutableList())
|
||||
state.copy(selection = selection)
|
||||
}
|
||||
}
|
||||
|
||||
fun invertSelection() {
|
||||
mutableState.update { state ->
|
||||
val newItems = state.titles.map {
|
||||
selectedMangaIds.addOrRemove(it.manga.id, !it.selected)
|
||||
it.copy(selected = !it.selected)
|
||||
val selection = state.selection.mutate { list ->
|
||||
state.titles.forEach { item ->
|
||||
if (!list.remove(item.id)) list.add(item.id)
|
||||
}
|
||||
}
|
||||
selectedPositions[0] = -1
|
||||
selectedPositions[1] = -1
|
||||
state.copy(titleList = newItems.toImmutableList())
|
||||
state.copy(selection = selection)
|
||||
}
|
||||
}
|
||||
// KMK <--
|
||||
|
|
@ -177,13 +189,11 @@ class MigrateMangaScreenModel(
|
|||
@Immutable
|
||||
data class State(
|
||||
val source: Source? = null,
|
||||
private val titleList: ImmutableList<MigrateMangaItem>? = null,
|
||||
val selection: Set<Long> = emptySet(),
|
||||
private val titleList: ImmutableList<Manga>? = null,
|
||||
) {
|
||||
// KMK -->
|
||||
val selection = titles.filter { it.selected }
|
||||
// KMK <--
|
||||
|
||||
val titles: ImmutableList<MigrateMangaItem>
|
||||
val titles: ImmutableList<Manga>
|
||||
get() = titleList ?: persistentListOf()
|
||||
|
||||
val isLoading: Boolean
|
||||
|
|
@ -199,11 +209,3 @@ class MigrateMangaScreenModel(
|
|||
sealed interface MigrationMangaEvent {
|
||||
data object FailedFetchingFavorites : MigrationMangaEvent
|
||||
}
|
||||
|
||||
// KMK -->
|
||||
@Immutable
|
||||
data class MigrateMangaItem(
|
||||
val manga: Manga,
|
||||
val selected: Boolean,
|
||||
)
|
||||
// KMK <--
|
||||
|
|
|
|||
|
|
@ -375,6 +375,7 @@ data class BrowseSourceScreen(
|
|||
},
|
||||
onMangaLongClick = { manga ->
|
||||
// KMK -->
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
if (bulkFavoriteState.selectionMode) {
|
||||
navigator.push(MangaScreen(manga.id, true))
|
||||
} else {
|
||||
|
|
@ -388,7 +389,6 @@ data class BrowseSourceScreen(
|
|||
)
|
||||
else -> screenModel.addFavorite(manga)
|
||||
}
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -298,14 +298,14 @@ class HistoryScreenModel(
|
|||
selectionOptions: HistorySelectionOptions,
|
||||
) {
|
||||
val (selected, fromLongPress) = selectionOptions
|
||||
if (item.chapterId in state.value.selection == selected) return
|
||||
|
||||
mutableState.update { state ->
|
||||
if (item.chapterId in state.selection == selected) return@update state
|
||||
val selectedIndex = state.list.indexOfFirst { it.chapterId == item.chapterId }
|
||||
if (selectedIndex < 0) return@update state
|
||||
|
||||
val selection = state.selection.mutate { list ->
|
||||
state.list.run {
|
||||
val selectedIndex = indexOfFirst { it.chapterId == item.chapterId }
|
||||
if (selectedIndex < 0) return@run
|
||||
|
||||
val firstSelection = list.isEmpty()
|
||||
if (selected) list.add(item.chapterId) else list.remove(item.chapterId)
|
||||
|
||||
|
|
|
|||
|
|
@ -94,10 +94,14 @@ import tachiyomi.domain.category.interactor.GetCategories
|
|||
import tachiyomi.domain.category.interactor.SetMangaCategories
|
||||
import tachiyomi.domain.category.model.Category
|
||||
import tachiyomi.domain.category.model.Category.Companion.UNCATEGORIZED_ID
|
||||
import tachiyomi.domain.chapter.interactor.GetBookmarkedChaptersByMangaId
|
||||
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
|
||||
import tachiyomi.domain.chapter.interactor.GetMergedChaptersByMangaId
|
||||
import tachiyomi.domain.chapter.model.Chapter
|
||||
import tachiyomi.domain.history.interactor.GetNextChapters
|
||||
import tachiyomi.domain.chapter.service.getChapterSort
|
||||
import tachiyomi.domain.history.interactor.GetCrossMangaPageRateHistorySamples
|
||||
import tachiyomi.domain.history.interactor.GetHistoryRevision
|
||||
import tachiyomi.domain.history.interactor.GetReadDurationEntriesByMangaIds
|
||||
import tachiyomi.domain.history.interactor.GetTotalReadDuration
|
||||
import tachiyomi.domain.library.model.LibraryDisplayMode
|
||||
|
|
@ -106,6 +110,9 @@ import tachiyomi.domain.library.model.LibraryManga
|
|||
import tachiyomi.domain.library.model.LibrarySort
|
||||
import tachiyomi.domain.library.model.sort
|
||||
import tachiyomi.domain.library.service.LibraryPreferences
|
||||
import tachiyomi.domain.reading.ReadingEtaLimits
|
||||
import tachiyomi.domain.reading.SeriesReadingEtaCalculator
|
||||
import tachiyomi.domain.reading.service.ReadingEtaPreferences
|
||||
import tachiyomi.domain.manga.interactor.GetIdsOfFavoriteMangaWithMetadata
|
||||
import tachiyomi.domain.manga.interactor.GetLibraryManga
|
||||
import tachiyomi.domain.manga.interactor.GetMergedMangaById
|
||||
|
|
@ -136,6 +143,7 @@ class LibraryScreenModel(
|
|||
private val getTracksPerManga: GetTracksPerManga = Injekt.get(),
|
||||
private val getNextChapters: GetNextChapters = Injekt.get(),
|
||||
private val getChaptersByMangaId: GetChaptersByMangaId = Injekt.get(),
|
||||
private val getBookmarkedChaptersByMangaId: GetBookmarkedChaptersByMangaId = Injekt.get(),
|
||||
private val setReadStatus: SetReadStatus = Injekt.get(),
|
||||
private val updateManga: UpdateManga = Injekt.get(),
|
||||
private val setMangaCategories: SetMangaCategories = Injekt.get(),
|
||||
|
|
@ -151,6 +159,8 @@ class LibraryScreenModel(
|
|||
private val sourcePreferences: SourcePreferences = Injekt.get(),
|
||||
private val getMergedMangaById: GetMergedMangaById = Injekt.get(),
|
||||
private val getReadDurationEntriesByMangaIds: GetReadDurationEntriesByMangaIds = Injekt.get(),
|
||||
private val getCrossMangaPageRateHistorySamples: GetCrossMangaPageRateHistorySamples = Injekt.get(),
|
||||
private val getHistoryRevision: GetHistoryRevision = Injekt.get(),
|
||||
private val getTotalReadDuration: GetTotalReadDuration = Injekt.get(),
|
||||
private val getTracks: GetTracks = Injekt.get(),
|
||||
private val getIdsOfFavoriteMangaWithMetadata: GetIdsOfFavoriteMangaWithMetadata = Injekt.get(),
|
||||
|
|
@ -163,6 +173,7 @@ class LibraryScreenModel(
|
|||
// SY <--
|
||||
// KMK -->
|
||||
private val smartSearchMerge: SmartSearchMerge = Injekt.get(),
|
||||
private val readingEtaPreferences: ReadingEtaPreferences = Injekt.get(),
|
||||
// KMK <--
|
||||
) : StateScreenModel<LibraryScreenModel.State>(State()) {
|
||||
|
||||
|
|
@ -194,8 +205,9 @@ class LibraryScreenModel(
|
|||
),
|
||||
// KMK <--
|
||||
getLibraryItemPreferencesFlow(),
|
||||
) { (searchQuery, categories, favorites), (tracksMap, trackingFilters), (includedCategories, excludedCategories), itemPreferences ->
|
||||
val estimatedTimeLeftByMangaId = computeEstimatedTimeLeftByMangaId(favorites)
|
||||
readingEtaPreferences.limitsFlow(),
|
||||
) { (searchQuery, categories, favorites), (tracksMap, trackingFilters), (includedCategories, excludedCategories), itemPreferences, etaLimits ->
|
||||
val estimatedTimeLeftByMangaId = computeEstimatedTimeLeftByMangaId(favorites, etaLimits)
|
||||
val favoritesWithEstimatedTimeLeft = favorites.map { item ->
|
||||
item.copy(
|
||||
estimatedTimeLeftMillis = if (itemPreferences.estimatedTimeLeftBadge) {
|
||||
|
|
@ -439,62 +451,61 @@ class LibraryScreenModel(
|
|||
|
||||
private suspend fun computeEstimatedTimeLeftByMangaId(
|
||||
favorites: List<LibraryItem>,
|
||||
limits: ReadingEtaLimits,
|
||||
): Map<Long, Long?> {
|
||||
if (favorites.isEmpty()) return emptyMap()
|
||||
|
||||
val chapterDurationsByMangaId = favorites.associate { item ->
|
||||
item.id to getReadDurationEntriesByMangaIds.await(item.id).map { it.readDuration }
|
||||
}
|
||||
val globalAverageChapterDurationMs = calculateAverageChapterDurationMs(
|
||||
chapterDurationsByMangaId.values.flatten(),
|
||||
)
|
||||
|
||||
return favorites.associate { item ->
|
||||
item.id to calculateEstimatedTimeLeftMs(
|
||||
item = item,
|
||||
chapterDurations = chapterDurationsByMangaId[item.id].orEmpty(),
|
||||
globalAverageChapterDurationMs = globalAverageChapterDurationMs,
|
||||
)
|
||||
item.id to computeEstimatedTimeLeftMs(item, limits)
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateEstimatedTimeLeftMs(
|
||||
private suspend fun computeEstimatedTimeLeftMs(
|
||||
item: LibraryItem,
|
||||
chapterDurations: List<Long>,
|
||||
globalAverageChapterDurationMs: Long?,
|
||||
limits: ReadingEtaLimits,
|
||||
): Long? {
|
||||
val unreadCount = item.libraryManga.unreadCount.coerceAtLeast(0L)
|
||||
if (unreadCount == 0L) return null
|
||||
if (item.libraryManga.unreadCount <= 0L) return null
|
||||
|
||||
val averageChapterDurationMs = calculateAverageChapterDurationMs(chapterDurations)
|
||||
?: globalAverageChapterDurationMs
|
||||
?: return null
|
||||
|
||||
val remainingChapterUnits = if (item.libraryManga.totalChapters == 1L) {
|
||||
1L
|
||||
val manga = item.libraryManga.manga
|
||||
val mergedManga = if (manga.source == MERGED_SOURCE_ID) {
|
||||
getMergedMangaById.await(manga.id).associateBy { it.id }
|
||||
} else {
|
||||
unreadCount
|
||||
emptyMap()
|
||||
}
|
||||
return (averageChapterDurationMs * remainingChapterUnits).coerceAtLeast(0L)
|
||||
}
|
||||
|
||||
private fun calculateAverageChapterDurationMs(
|
||||
chapterDurations: List<Long>,
|
||||
): Long? {
|
||||
val sampledDurations = chapterDurations
|
||||
.filter { it in MIN_TRACKED_CHAPTER_DURATION_MS..MAX_TRACKED_CHAPTER_DURATION_MS }
|
||||
.takeLast(ETA_SAMPLE_CHAPTER_LIMIT)
|
||||
if (sampledDurations.isEmpty()) return null
|
||||
|
||||
val sortedDurations = sampledDurations.sorted()
|
||||
val trimCount = (sortedDurations.size / 10)
|
||||
.coerceAtMost((sortedDurations.size - 1) / 2)
|
||||
val trimmedDurations = if (trimCount > 0) {
|
||||
sortedDurations.subList(trimCount, sortedDurations.size - trimCount)
|
||||
val rawChapters = if (manga.source == MERGED_SOURCE_ID) {
|
||||
getMergedChaptersByMangaId.await(manga.id, applyFilter = true)
|
||||
} else {
|
||||
sortedDurations
|
||||
getChaptersByMangaId.await(manga.id, applyFilter = true)
|
||||
}
|
||||
return trimmedDurations.sum() / trimmedDurations.size
|
||||
val orderedChapters = if (manga.source == MERGED_SOURCE_ID) {
|
||||
getMergedChaptersByMangaId.await(manga.id, applyFilter = false)
|
||||
} else {
|
||||
getChaptersByMangaId.await(manga.id, applyFilter = false)
|
||||
}.sortedWith(getChapterSort(manga, sortDescending = false))
|
||||
if (orderedChapters.isEmpty()) return null
|
||||
|
||||
val nextUnread = rawChapters.getNextUnread(manga, downloadManager, mergedManga) ?: return null
|
||||
val histories = getReadDurationEntriesByMangaIds.await(manga.id)
|
||||
val crossMangaSamples = getCrossMangaPageRateHistorySamples.await(manga.id)
|
||||
val currentPage = if (nextUnread.lastPageRead > 0L) {
|
||||
(nextUnread.lastPageRead + 1L).toInt()
|
||||
} else {
|
||||
1
|
||||
}
|
||||
|
||||
return SeriesReadingEtaCalculator.estimate(
|
||||
SeriesReadingEtaCalculator.Input(
|
||||
orderedChapterIds = orderedChapters.map { it.id },
|
||||
chapterReadById = orderedChapters.associate { it.id to it.read },
|
||||
currentChapterId = nextUnread.id,
|
||||
currentPage = currentPage,
|
||||
totalPages = -1,
|
||||
chapterDurations = histories.associate { it.chapterId to it.readDuration },
|
||||
chapterPages = histories.associate { it.chapterId to it.readPages },
|
||||
crossMangaPageRateSamples = crossMangaSamples,
|
||||
limits = limits,
|
||||
),
|
||||
)?.etaMillis
|
||||
}
|
||||
|
||||
private fun compareEstimatedTimeLeft(
|
||||
|
|
@ -540,15 +551,6 @@ class LibraryScreenModel(
|
|||
val filterLewd = preferences.filterLewd
|
||||
// SY <--
|
||||
|
||||
// KMK -->
|
||||
// Pre-compute track mappings for better performance
|
||||
val mangaTrackIds = if (!isNotLoggedInAnyTrack && !trackFiltersIsIgnored) {
|
||||
trackMap.mapValues { entry -> entry.value.map { it.trackerId } }
|
||||
} else {
|
||||
emptyMap()
|
||||
}
|
||||
// KMK <--
|
||||
|
||||
val filterFnDownloaded: suspend (LibraryItem) -> Boolean = {
|
||||
applyFilter(filterDownloaded) {
|
||||
it.libraryManga.manga.isLocal() ||
|
||||
|
|
@ -600,15 +602,12 @@ class LibraryScreenModel(
|
|||
if (isNotLoggedInAnyTrack || trackFiltersIsIgnored) return@tracking true
|
||||
|
||||
// KMK -->
|
||||
val mangaTracks = mangaTrackIds[item.id].orEmpty()
|
||||
val mangaTracks = trackMap[item.id].orEmpty()
|
||||
|
||||
// Early return
|
||||
if (mangaTracks.isEmpty() && includedTracks.isNotEmpty()) return@tracking false
|
||||
val isExcluded = excludedTracks.isNotEmpty() && mangaTracks.fastAny { it.trackerId in excludedTracks }
|
||||
val isIncluded = includedTracks.isEmpty() || mangaTracks.fastAny { it.trackerId in includedTracks }
|
||||
// KMK <--
|
||||
|
||||
val isExcluded = excludedTracks.isNotEmpty() && mangaTracks.fastAny { it in excludedTracks }
|
||||
val isIncluded = includedTracks.isEmpty() || mangaTracks.fastAny { it in includedTracks }
|
||||
|
||||
!isExcluded && isIncluded
|
||||
}
|
||||
|
||||
|
|
@ -896,7 +895,8 @@ class LibraryScreenModel(
|
|||
getLibraryManga.subscribe(),
|
||||
getLibraryItemPreferencesFlow(),
|
||||
downloadCache.changes,
|
||||
) { libraryManga, preferences, _ ->
|
||||
getHistoryRevision.subscribe(),
|
||||
) { libraryManga, preferences, _, _ ->
|
||||
libraryManga.map { manga ->
|
||||
// Display mode based on user preference: take it from global library setting or category
|
||||
// KMK -->
|
||||
|
|
@ -1009,15 +1009,19 @@ class LibraryScreenModel(
|
|||
* Queues the amount specified of unread chapters from the list of selected manga
|
||||
*/
|
||||
fun performDownloadAction(action: DownloadAction) {
|
||||
val mangas = state.value.selectedManga
|
||||
val amount = when (action) {
|
||||
DownloadAction.NEXT_1_CHAPTER -> 1
|
||||
DownloadAction.NEXT_5_CHAPTERS -> 5
|
||||
DownloadAction.NEXT_10_CHAPTERS -> 10
|
||||
DownloadAction.NEXT_25_CHAPTERS -> 25
|
||||
DownloadAction.UNREAD_CHAPTERS -> null
|
||||
when (action) {
|
||||
DownloadAction.NEXT_1_CHAPTER -> downloadNextChapters(1)
|
||||
DownloadAction.NEXT_5_CHAPTERS -> downloadNextChapters(5)
|
||||
DownloadAction.NEXT_10_CHAPTERS -> downloadNextChapters(10)
|
||||
DownloadAction.NEXT_25_CHAPTERS -> downloadNextChapters(25)
|
||||
DownloadAction.UNREAD_CHAPTERS -> downloadNextChapters(null)
|
||||
DownloadAction.BOOKMARKED_CHAPTERS -> downloadBookmarkedChapters()
|
||||
}
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
private fun downloadNextChapters(amount: Int?) {
|
||||
val mangas = state.value.selectedManga
|
||||
screenModelScope.launchNonCancellable {
|
||||
mangas.forEach { manga ->
|
||||
// SY -->
|
||||
|
|
@ -1067,6 +1071,54 @@ class LibraryScreenModel(
|
|||
}
|
||||
}
|
||||
|
||||
private fun downloadBookmarkedChapters() {
|
||||
val mangas = state.value.selectedManga
|
||||
screenModelScope.launchNonCancellable {
|
||||
mangas.forEach { manga ->
|
||||
// SY -->
|
||||
if (manga.source == MERGED_SOURCE_ID) {
|
||||
val mergedMangas = getMergedMangaById.await(manga.id)
|
||||
.associateBy { it.id }
|
||||
getBookmarkedChaptersByMangaId.await(manga.id)
|
||||
.groupBy { it.mangaId }
|
||||
.forEach ab@{ (mangaId, chapters) ->
|
||||
val mergedManga = mergedMangas[mangaId] ?: return@ab
|
||||
val downloadChapters = chapters.fastFilterNot { chapter ->
|
||||
downloadManager.queueState.value.fastAny { chapter.id == it.chapter.id } ||
|
||||
downloadManager.isChapterDownloaded(
|
||||
chapter.name,
|
||||
chapter.scanlator,
|
||||
chapter.url,
|
||||
mergedManga.ogTitle,
|
||||
mergedManga.source,
|
||||
)
|
||||
}
|
||||
|
||||
downloadManager.downloadChapters(mergedManga, downloadChapters)
|
||||
}
|
||||
|
||||
return@forEach
|
||||
}
|
||||
// SY <--
|
||||
|
||||
val chapters = getBookmarkedChaptersByMangaId.await(manga.id)
|
||||
.fastFilterNot { chapter ->
|
||||
downloadManager.getQueuedDownloadOrNull(chapter.id) != null ||
|
||||
downloadManager.isChapterDownloaded(
|
||||
chapter.name,
|
||||
chapter.scanlator,
|
||||
chapter.url,
|
||||
// SY -->
|
||||
manga.ogTitle,
|
||||
// SY <--
|
||||
manga.source,
|
||||
)
|
||||
}
|
||||
downloadManager.downloadChapters(manga, chapters)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SY -->
|
||||
fun cleanTitles() {
|
||||
val regex1 = "\\[.*?]".toRegex()
|
||||
|
|
@ -1876,10 +1928,6 @@ class LibraryScreenModel(
|
|||
|
||||
// KMK -->
|
||||
companion object {
|
||||
private const val MIN_TRACKED_CHAPTER_DURATION_MS = 2_000L
|
||||
private const val MAX_TRACKED_CHAPTER_DURATION_MS = 3_600_000L
|
||||
private const val ETA_SAMPLE_CHAPTER_LIMIT = 60
|
||||
|
||||
/** List of MangaDex UUIDs subject to DMCA takedowns */
|
||||
@Volatile
|
||||
private var mangaDexDmcaUuids = hashSetOf<String>()
|
||||
|
|
|
|||
|
|
@ -329,8 +329,8 @@ data object LibraryTab : Tab {
|
|||
}.takeIf { state.showMangaContinueButton },
|
||||
onToggleSelection = screenModel::toggleSelection,
|
||||
onToggleRangeSelection = { category, manga ->
|
||||
screenModel.toggleRangeSelection(category, manga)
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
screenModel.toggleRangeSelection(category, manga)
|
||||
},
|
||||
onRefresh = { onClickRefresh(state.activeCategory) },
|
||||
onGlobalSearchClicked = {
|
||||
|
|
|
|||
|
|
@ -62,22 +62,20 @@ class LibraryUpdateErrorScreenModel(
|
|||
fun toggleSelection(
|
||||
item: LibraryUpdateErrorItem,
|
||||
selected: Boolean,
|
||||
userSelected: Boolean = false,
|
||||
fromLongPress: Boolean = false,
|
||||
) {
|
||||
mutableState.update { state ->
|
||||
val selectedIndex = state.items.indexOfFirst { it.error.errorId == item.error.errorId }
|
||||
if (selectedIndex < 0) return@update state
|
||||
val selectedItem = state.items[selectedIndex]
|
||||
if (selectedItem.selected == selected) return@update state
|
||||
|
||||
val newItems = state.items.toMutableList().apply {
|
||||
val selectedIndex = indexOfFirst { it.error.errorId == item.error.errorId }
|
||||
if (selectedIndex < 0) return@apply
|
||||
|
||||
val selectedItem = get(selectedIndex)
|
||||
if (selectedItem.selected == selected) return@apply
|
||||
|
||||
val firstSelection = none { it.selected }
|
||||
set(selectedIndex, selectedItem.copy(selected = selected))
|
||||
selectedErrorIds.addOrRemove(item.error.errorId, selected)
|
||||
|
||||
if (selected && userSelected && fromLongPress) {
|
||||
if (selected && fromLongPress) {
|
||||
if (firstSelection) {
|
||||
selectedPositions[0] = selectedIndex
|
||||
selectedPositions[1] = selectedIndex
|
||||
|
|
@ -96,14 +94,14 @@ class LibraryUpdateErrorScreenModel(
|
|||
}
|
||||
|
||||
range.forEach {
|
||||
val inbetweenItem = get(it)
|
||||
if (!inbetweenItem.selected) {
|
||||
selectedErrorIds.add(inbetweenItem.error.errorId)
|
||||
set(it, inbetweenItem.copy(selected = true))
|
||||
val inBetweenItem = get(it)
|
||||
if (!inBetweenItem.selected) {
|
||||
selectedErrorIds.add(inBetweenItem.error.errorId)
|
||||
set(it, inBetweenItem.copy(selected = true))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (userSelected && !fromLongPress) {
|
||||
} else if (!fromLongPress) {
|
||||
if (!selected) {
|
||||
if (selectedIndex == selectedPositions[0]) {
|
||||
selectedPositions[0] = indexOfFirst { it.selected }
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue