Compare commits
84 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 | ||
|
|
e50767709a | ||
|
|
c290472012 | ||
|
|
aa7198f082 | ||
|
|
a52609b549 | ||
|
|
4c21f019c0 |
247 changed files with 6243 additions and 3354 deletions
|
|
@ -5,6 +5,12 @@ on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- v*
|
- 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:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
@ -21,6 +27,7 @@ jobs:
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
DISPATCH_TAG: ${{ github.event.inputs.tag }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
rm -rf .git
|
rm -rf .git
|
||||||
|
|
@ -28,7 +35,11 @@ jobs:
|
||||||
origin_url="${GITHUB_SERVER_URL#https://}"
|
origin_url="${GITHUB_SERVER_URL#https://}"
|
||||||
origin_url="${origin_url#http://}"
|
origin_url="${origin_url#http://}"
|
||||||
git remote add origin "https://copilot:${FORGEJO_TOKEN}@${origin_url}/${GITHUB_REPOSITORY}.git"
|
git remote add origin "https://copilot:${FORGEJO_TOKEN}@${origin_url}/${GITHUB_REPOSITORY}.git"
|
||||||
git -c http.sslVerify=false fetch --tags --prune 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
|
git checkout --force FETCH_HEAD
|
||||||
|
|
||||||
- name: Set up JDK
|
- name: Set up JDK
|
||||||
|
|
@ -86,7 +97,11 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
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//' )"
|
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)"
|
previous_tag="$(git tag --list 'v*' --sort=-version:refname | grep -Fxv "$version_tag" | head -n 1 || true)"
|
||||||
upstream_repo="komikku-app/komikku"
|
upstream_repo="komikku-app/komikku"
|
||||||
|
|
@ -112,11 +127,11 @@ jobs:
|
||||||
git fetch --no-tags --depth=1 "https://github.com/${upstream_repo}.git" "$upstream_base" || true
|
git fetch --no-tags --depth=1 "https://github.com/${upstream_repo}.git" "$upstream_base" || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
use_fork_merge_split=0
|
# 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=""
|
log_range=""
|
||||||
if [ -n "$upstream_base" ] && git cat-file -e "${upstream_base}^{commit}" 2>/dev/null; then
|
if [ -n "$previous_tag" ]; then
|
||||||
use_fork_merge_split=1
|
|
||||||
elif [ -n "$previous_tag" ]; then
|
|
||||||
log_range="${previous_tag}..HEAD"
|
log_range="${previous_tag}..HEAD"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -209,37 +224,22 @@ jobs:
|
||||||
[ "$any" -eq 1 ]
|
[ "$any" -eq 1 ]
|
||||||
}
|
}
|
||||||
|
|
||||||
if [ "$use_fork_merge_split" -eq 1 ]; then
|
if [ -n "$log_range" ]; then
|
||||||
{
|
total_in_range="$(git rev-list --count "${log_range}" 2>/dev/null || printf '0')"
|
||||||
printf '%s\n' "#### Fork changes" ""
|
|
||||||
clear_buckets
|
|
||||||
fill_buckets_from_git "${upstream_base}..HEAD" --no-merges
|
|
||||||
if [ ${#new_lines[@]} -eq 0 ] && [ ${#fix_lines[@]} -eq 0 ] && [ ${#improve_lines[@]} -eq 0 ] && [ ${#other_lines[@]} -eq 0 ]; then
|
|
||||||
printf '%s\n' "_No direct commits in this range._" ""
|
|
||||||
else
|
|
||||||
emit_buckets_from_arrays
|
|
||||||
fi
|
|
||||||
|
|
||||||
clear_buckets
|
|
||||||
fill_buckets_from_git "${upstream_base}..HEAD" --merges
|
|
||||||
if [ ${#new_lines[@]} -gt 0 ] || [ ${#fix_lines[@]} -gt 0 ] || [ ${#improve_lines[@]} -gt 0 ] || [ ${#other_lines[@]} -gt 0 ]; then
|
|
||||||
printf '%s\n' "#### Merge commits" ""
|
|
||||||
emit_buckets_from_arrays
|
|
||||||
printf '\n'
|
|
||||||
fi
|
|
||||||
} > "${commit_log_file}"
|
|
||||||
elif [ -n "$log_range" ]; then
|
|
||||||
clear_buckets
|
clear_buckets
|
||||||
fill_buckets_from_git "$log_range"
|
fill_buckets_from_git "$log_range" "-n${changelog_commit_limit}"
|
||||||
{
|
{
|
||||||
if emit_buckets_from_arrays; then
|
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
|
else
|
||||||
printf '%s\n' "##### Other" "" "- Initial release" ""
|
printf '%s\n' "##### Other" "" "- No commit messages in this tag range." ""
|
||||||
fi
|
fi
|
||||||
} > "${commit_log_file}"
|
} > "${commit_log_file}"
|
||||||
else
|
else
|
||||||
printf '%s\n' "##### Other" "" "- Initial release" "" > "${commit_log_file}"
|
printf '%s\n' "##### Other" "" "- First release tag, or no prior \`v*\` tag found — see repository history." "" > "${commit_log_file}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Do not pass multiline commit text via step outputs / env (breaks some Forgejo/act_runner
|
# Do not pass multiline commit text via step outputs / env (breaks some Forgejo/act_runner
|
||||||
|
|
@ -298,12 +298,12 @@ jobs:
|
||||||
printf 'sdk.dir=%s\n' "$ANDROID_HOME" > local.properties
|
printf 'sdk.dir=%s\n' "$ANDROID_HOME" > local.properties
|
||||||
chmod +x ./gradlew
|
chmod +x ./gradlew
|
||||||
./gradlew --no-daemon --max-workers=1 \
|
./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.parallel=false \
|
||||||
-Dorg.gradle.configureondemand=false \
|
-Dorg.gradle.configureondemand=false \
|
||||||
-Dorg.gradle.vfs.watch=false \
|
-Dorg.gradle.vfs.watch=false \
|
||||||
-Dkotlin.daemon.enabled=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 \
|
-Dkotlin.compiler.execution.strategy=in-process \
|
||||||
-Pkotlin.incremental=true \
|
-Pkotlin.incremental=true \
|
||||||
--version
|
--version
|
||||||
|
|
@ -314,12 +314,12 @@ jobs:
|
||||||
printf 'sdk.dir=%s\n' "$ANDROID_HOME" > local.properties
|
printf 'sdk.dir=%s\n' "$ANDROID_HOME" > local.properties
|
||||||
fi
|
fi
|
||||||
./gradlew --no-daemon --max-workers=1 \
|
./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.parallel=false \
|
||||||
-Dorg.gradle.configureondemand=false \
|
-Dorg.gradle.configureondemand=false \
|
||||||
-Dorg.gradle.vfs.watch=false \
|
-Dorg.gradle.vfs.watch=false \
|
||||||
-Dkotlin.daemon.enabled=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 \
|
-Dkotlin.compiler.execution.strategy=in-process \
|
||||||
-Pkotlin.incremental=true \
|
-Pkotlin.incremental=true \
|
||||||
--stacktrace \
|
--stacktrace \
|
||||||
|
|
@ -386,6 +386,33 @@ jobs:
|
||||||
"${apksigner_bin}" verify "${signed_apk}"
|
"${apksigner_bin}" verify "${signed_apk}"
|
||||||
done
|
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
|
- name: Create Obtainium-friendly aliases
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -421,9 +448,6 @@ jobs:
|
||||||
VERSION_TAG: ${{ steps.prepare.outputs.VERSION_TAG }}
|
VERSION_TAG: ${{ steps.prepare.outputs.VERSION_TAG }}
|
||||||
VERSION_DISPLAY: ${{ steps.prepare.outputs.VERSION_DISPLAY }}
|
VERSION_DISPLAY: ${{ steps.prepare.outputs.VERSION_DISPLAY }}
|
||||||
PREV_TAG_NAME: ${{ steps.prepare.outputs.PREV_TAG_NAME }}
|
PREV_TAG_NAME: ${{ steps.prepare.outputs.PREV_TAG_NAME }}
|
||||||
UPSTREAM_REPO: ${{ steps.prepare.outputs.UPSTREAM_REPO }}
|
|
||||||
UPSTREAM_REF: ${{ steps.prepare.outputs.UPSTREAM_REF }}
|
|
||||||
UPSTREAM_BASE: ${{ steps.prepare.outputs.UPSTREAM_BASE }}
|
|
||||||
REPO_URL: ${{ github.server_url }}/${{ github.repository }}
|
REPO_URL: ${{ github.server_url }}/${{ github.repository }}
|
||||||
REPOSITORY_NAME: ${{ github.repository }}
|
REPOSITORY_NAME: ${{ github.repository }}
|
||||||
FORGEJO_CURL_INSECURE: "true"
|
FORGEJO_CURL_INSECURE: "true"
|
||||||
|
|
@ -522,10 +546,6 @@ jobs:
|
||||||
if [ -n "${PREV_TAG_NAME:-}" ]; then
|
if [ -n "${PREV_TAG_NAME:-}" ]; then
|
||||||
full_changelog="**Full Changelog**: [${REPOSITORY_NAME}@${PREV_TAG_NAME}...${VERSION_TAG}](${REPO_URL}/compare/${PREV_TAG_NAME}...${VERSION_TAG})"
|
full_changelog="**Full Changelog**: [${REPOSITORY_NAME}@${PREV_TAG_NAME}...${VERSION_TAG}](${REPO_URL}/compare/${PREV_TAG_NAME}...${VERSION_TAG})"
|
||||||
fi
|
fi
|
||||||
fork_delta_label=""
|
|
||||||
if [ -n "${UPSTREAM_BASE:-}" ] && [ -n "${UPSTREAM_REF:-}" ]; then
|
|
||||||
fork_delta_label="Fork delta vs ${UPSTREAM_REPO}@${UPSTREAM_REF} (${UPSTREAM_BASE})"
|
|
||||||
fi
|
|
||||||
|
|
||||||
release_body_file="$(mktemp)"
|
release_body_file="$(mktemp)"
|
||||||
{
|
{
|
||||||
|
|
@ -533,13 +553,11 @@ jobs:
|
||||||
"#### What's Changed" \
|
"#### What's Changed" \
|
||||||
"" \
|
"" \
|
||||||
"${commit_logs}" \
|
"${commit_logs}" \
|
||||||
"" \
|
""
|
||||||
"##### Based on" \
|
if [ -n "${full_changelog}" ]; then
|
||||||
"" \
|
printf '%s\n' "${full_changelog}" ""
|
||||||
"${fork_delta_label}" \
|
fi
|
||||||
"" \
|
printf '%s\n' \
|
||||||
"${full_changelog}" \
|
|
||||||
"" \
|
|
||||||
"<!-->" \
|
"<!-->" \
|
||||||
"> [!TIP]" \
|
"> [!TIP]" \
|
||||||
">" \
|
">" \
|
||||||
|
|
|
||||||
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
|
echo "VERSION_TAG=$version_tag" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Clone Repository (${{ steps.get_tag.outputs.VERSION_TAG }})
|
- name: Clone Repository (${{ steps.get_tag.outputs.VERSION_TAG }})
|
||||||
uses: actions/checkout@v4 # Forgejo-compatible (node20)
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
|
@ -57,18 +57,18 @@ jobs:
|
||||||
needs: prepare-build
|
needs: prepare-build
|
||||||
steps:
|
steps:
|
||||||
- name: Clone Repository (${{ needs.prepare-build.outputs.TAG_NAME }})
|
- name: Clone Repository (${{ needs.prepare-build.outputs.TAG_NAME }})
|
||||||
uses: actions/checkout@v4 # Forgejo-compatible (node20)
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up JDK
|
- name: Set up JDK
|
||||||
uses: actions/setup-java@v4 # Forgejo-compatible (node20)
|
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
|
||||||
with:
|
with:
|
||||||
java-version: 17
|
java-version-file: .github/.java-version
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
|
|
||||||
- name: Set up gradle
|
- 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
|
- name: Check code format
|
||||||
run: ./gradlew spotlessCheck
|
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
|
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
|
- name: Upload artifacts
|
||||||
uses: actions/upload-artifact@v4 # Forgejo-compatible (node20)
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
path: "**/*.apk"
|
path: "**/*.apk"
|
||||||
retention-days: 30
|
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:
|
steps:
|
||||||
- name: Clone repo
|
- name: Clone repo
|
||||||
uses: actions/checkout@v4 # Forgejo-compatible (node20)
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
|
@ -53,7 +53,7 @@ jobs:
|
||||||
uses: tj-actions/branch-names@5250492686b253f06fa55861556d1027b067aeb5 # v9.0.2
|
uses: tj-actions/branch-names@5250492686b253f06fa55861556d1027b067aeb5 # v9.0.2
|
||||||
|
|
||||||
- name: Invoke workflow in preview repo
|
- 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:
|
with:
|
||||||
workflow: build_app.yml
|
workflow: build_app.yml
|
||||||
repo: komikku-app/komikku-preview
|
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
|
echo "VERSION_TAG=$version_tag" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Clone Repository (${{ steps.get_tag.outputs.VERSION_TAG }})
|
- name: Clone Repository (${{ steps.get_tag.outputs.VERSION_TAG }})
|
||||||
uses: actions/checkout@v4 # Forgejo-compatible (node20)
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
|
@ -119,14 +119,14 @@ jobs:
|
||||||
if: github.ref == 'refs/heads/master'
|
if: github.ref == 'refs/heads/master'
|
||||||
steps:
|
steps:
|
||||||
- name: Clone Repository (${{ needs.prepare-build.outputs.VERSION_TAG }} - ${{ needs.prepare-build.outputs.TAG_NAME }})
|
- 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:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up JDK
|
- name: Set up JDK
|
||||||
uses: actions/setup-java@v4 # Forgejo-compatible (node20)
|
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
|
||||||
with:
|
with:
|
||||||
java-version: 17
|
java-version-file: .github/.java-version
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
|
|
||||||
- name: Write google-services.json
|
- name: Write google-services.json
|
||||||
|
|
@ -144,7 +144,7 @@ jobs:
|
||||||
write-mode: overwrite
|
write-mode: overwrite
|
||||||
|
|
||||||
- name: Set up gradle
|
- 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
|
- name: Check code format
|
||||||
run: ./gradlew spotlessCheck
|
run: ./gradlew spotlessCheck
|
||||||
|
|
@ -156,7 +156,7 @@ jobs:
|
||||||
run: ./gradlew testReleaseUnitTest
|
run: ./gradlew testReleaseUnitTest
|
||||||
|
|
||||||
- name: Upload artifacts
|
- name: Upload artifacts
|
||||||
uses: actions/upload-artifact@v4 # Forgejo-compatible (node20)
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
path: "**/*.apk"
|
path: "**/*.apk"
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
@ -168,7 +168,7 @@ jobs:
|
||||||
- build-app
|
- build-app
|
||||||
steps:
|
steps:
|
||||||
- name: Download artifacts
|
- name: Download artifacts
|
||||||
uses: actions/download-artifact@v4 # Forgejo-compatible (node20)
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
with:
|
with:
|
||||||
merge-multiple: true
|
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
|
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
|
- name: Create release
|
||||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
|
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ needs.prepare-build.outputs.TAG_NAME }}
|
tag_name: ${{ needs.prepare-build.outputs.TAG_NAME }}
|
||||||
name: Komikku Preview ${{ needs.prepare-build.outputs.TAG_NAME }} (${{ needs.prepare-build.outputs.VERSION_TAG }})
|
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:
|
steps:
|
||||||
- name: Clone repo
|
- name: Clone repo
|
||||||
uses: actions/checkout@v4 # Forgejo-compatible (node20)
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Dependency Review
|
- 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
|
- 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
|
- name: Set up JDK
|
||||||
uses: actions/setup-java@v4 # Forgejo-compatible (node20)
|
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
|
||||||
with:
|
with:
|
||||||
java-version: 17
|
java-version-file: .github/.java-version
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
|
|
||||||
- name: Write google-services.json
|
- name: Write google-services.json
|
||||||
|
|
@ -63,7 +63,7 @@ jobs:
|
||||||
write-mode: overwrite
|
write-mode: overwrite
|
||||||
|
|
||||||
- name: Set up gradle
|
- 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
|
- name: Check code format
|
||||||
run: ./gradlew spotlessCheck
|
run: ./gradlew spotlessCheck
|
||||||
|
|
@ -82,7 +82,7 @@ jobs:
|
||||||
|
|
||||||
- name: Upload test report
|
- name: Upload test report
|
||||||
if: steps.unit_tests.outcome == 'failure'
|
if: steps.unit_tests.outcome == 'failure'
|
||||||
uses: actions/upload-artifact@v4 # Forgejo-compatible (node20)
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: test-report-${{ github.sha }}
|
name: test-report-${{ github.sha }}
|
||||||
path: app/build/reports/tests/testReleaseUnitTest
|
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
|
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
|
- name: Upload APK
|
||||||
uses: actions/upload-artifact@v4 # Forgejo-compatible (node20)
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: Komikku-${{ steps.current_commit.outputs.VERSION_TAG }}-r${{ steps.current_commit.outputs.COMMIT_COUNT }}.apk
|
name: Komikku-${{ steps.current_commit.outputs.VERSION_TAG }}-r${{ steps.current_commit.outputs.COMMIT_COUNT }}.apk
|
||||||
path: ./*.apk
|
path: ./*.apk
|
||||||
|
|
||||||
- name: Upload mapping
|
- name: Upload mapping
|
||||||
uses: actions/upload-artifact@v4 # Forgejo-compatible (node20)
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: mapping-${{ github.sha }}
|
name: mapping-${{ github.sha }}
|
||||||
path: app/build/outputs/mapping/preview
|
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:
|
steps:
|
||||||
- name: Clone repo
|
- name: Clone repo
|
||||||
uses: actions/checkout@v4 # Forgejo-compatible (node20)
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up JDK
|
- name: Set up JDK
|
||||||
uses: actions/setup-java@v4 # Forgejo-compatible (node20)
|
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
|
||||||
with:
|
with:
|
||||||
java-version: 17
|
java-version-file: .github/.java-version
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
|
|
||||||
- name: Write google-services.json
|
- name: Write google-services.json
|
||||||
|
|
@ -52,7 +52,7 @@ jobs:
|
||||||
write-mode: overwrite
|
write-mode: overwrite
|
||||||
|
|
||||||
- name: Set up gradle
|
- 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
|
- name: Check code format
|
||||||
run: ./gradlew spotlessCheck
|
run: ./gradlew spotlessCheck
|
||||||
|
|
@ -66,7 +66,7 @@ jobs:
|
||||||
|
|
||||||
- name: Upload test report
|
- name: Upload test report
|
||||||
if: steps.unit_tests.outcome == 'failure'
|
if: steps.unit_tests.outcome == 'failure'
|
||||||
uses: actions/upload-artifact@v4 # Forgejo-compatible (node20)
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: test-report-${{ github.sha }}
|
name: test-report-${{ github.sha }}
|
||||||
path: app/build/reports/tests/testReleaseUnitTest
|
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
|
mv app/build/outputs/apk/preview/app-universal-preview-signed.apk Komikku-$version_tag-r$commit_count.apk
|
||||||
|
|
||||||
- name: Upload APK
|
- name: Upload APK
|
||||||
uses: actions/upload-artifact@v4 # Forgejo-compatible (node20)
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: Komikku-${{ steps.current_commit.outputs.VERSION_TAG }}-r${{ steps.current_commit.outputs.COMMIT_COUNT }}.apk
|
name: Komikku-${{ steps.current_commit.outputs.VERSION_TAG }}-r${{ steps.current_commit.outputs.COMMIT_COUNT }}.apk
|
||||||
path: ./*.apk
|
path: ./*.apk
|
||||||
|
|
||||||
- name: Upload mapping
|
- name: Upload mapping
|
||||||
uses: actions/upload-artifact@v4 # Forgejo-compatible (node20)
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: mapping-${{ github.sha }}
|
name: mapping-${{ github.sha }}
|
||||||
path: app/build/outputs/mapping/preview
|
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
|
echo "VERSION_TAG=$version_tag" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Clone Repository (${{ steps.get_tag.outputs.VERSION_TAG }})
|
- name: Clone Repository (${{ steps.get_tag.outputs.VERSION_TAG }})
|
||||||
uses: actions/checkout@v4 # Forgejo-compatible (node20)
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
|
@ -87,14 +87,14 @@ jobs:
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
steps:
|
steps:
|
||||||
- name: Clone Repository (${{ needs.prepare-build.outputs.VERSION_TAG }})
|
- name: Clone Repository (${{ needs.prepare-build.outputs.VERSION_TAG }})
|
||||||
uses: actions/checkout@v4 # Forgejo-compatible (node20)
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up JDK
|
- name: Set up JDK
|
||||||
uses: actions/setup-java@v4 # Forgejo-compatible (node20)
|
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
|
||||||
with:
|
with:
|
||||||
java-version: 17
|
java-version-file: .github/.java-version
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
|
|
||||||
- name: Write google-services.json
|
- name: Write google-services.json
|
||||||
|
|
@ -112,7 +112,7 @@ jobs:
|
||||||
write-mode: overwrite
|
write-mode: overwrite
|
||||||
|
|
||||||
- name: Set up gradle
|
- 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
|
- name: Check code format
|
||||||
run: ./gradlew spotlessCheck
|
run: ./gradlew spotlessCheck
|
||||||
|
|
@ -124,13 +124,13 @@ jobs:
|
||||||
run: ./gradlew testReleaseUnitTest
|
run: ./gradlew testReleaseUnitTest
|
||||||
|
|
||||||
- name: Upload artifacts
|
- name: Upload artifacts
|
||||||
uses: actions/upload-artifact@v4 # Forgejo-compatible (node20)
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
path: "**/*.apk"
|
path: "**/*.apk"
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
- name: Upload mapping
|
- name: Upload mapping
|
||||||
uses: actions/upload-artifact@v4 # Forgejo-compatible (node20)
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: mapping-${{ github.sha }}
|
name: mapping-${{ github.sha }}
|
||||||
path: app/build/outputs/mapping/release
|
path: app/build/outputs/mapping/release
|
||||||
|
|
@ -142,7 +142,7 @@ jobs:
|
||||||
- build-app
|
- build-app
|
||||||
steps:
|
steps:
|
||||||
- name: Download artifacts
|
- name: Download artifacts
|
||||||
uses: actions/download-artifact@v4 # Forgejo-compatible (node20)
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
with:
|
with:
|
||||||
merge-multiple: true
|
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
|
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
|
- name: Create release
|
||||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
|
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ needs.prepare-build.outputs.VERSION_TAG }}
|
tag_name: ${{ needs.prepare-build.outputs.VERSION_TAG }}
|
||||||
name: Komikku ${{ 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'
|
if: github.repository == 'komikku-app/komikku'
|
||||||
runs-on: 'ubuntu-24.04'
|
runs-on: 'ubuntu-24.04'
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4 # Forgejo-compatible (node20)
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Setup SSH key
|
- 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
|
if: github.event.pull_request.merged == true && github.repository == github.event.pull_request.head.repo.full_name
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4 # Forgejo-compatible (node20)
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
|
||||||
- name: Delete branch
|
- name: Delete branch
|
||||||
run: |
|
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:
|
steps:
|
||||||
- name: Check PR and Add Label
|
- name: Check PR and Add Label
|
||||||
uses: actions/github-script@v7 # Forgejo-compatible (node20)
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const prAuthor = context.payload.pull_request.user.login;
|
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:
|
steps:
|
||||||
- name: Update website on release
|
- 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:
|
with:
|
||||||
workflow: Deploy
|
workflow: Deploy
|
||||||
repo: komikku-app/komikku-app.github.io
|
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]
|
## [Unreleased]
|
||||||
### Added
|
### 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))
|
- 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))
|
- 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 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:` 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))
|
- 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))
|
- 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))
|
- 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))
|
- 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
|
### Changed
|
||||||
- Update tracker icons ([@AntsyLich](https://github.com/AntsyLich)) ([#2773](https://github.com/mihonapp/mihon/pull/2773))
|
- 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 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
|
### 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))
|
- 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 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 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 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
|
### 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))
|
- 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.*
|
*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")
|
[](https://github.com/sponsors/cuong-tran "Sponsor me on GitHub")
|
||||||
|
|
||||||
<div align="left">
|
<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.
|
- Long-click to add/remove single entry to/from library, everywhere.
|
||||||
- Docking Read/Resume button to left/right.
|
- Docking Read/Resume button to left/right.
|
||||||
- In-app progress banner shows Library syncing / Backup restoring / Library updating progress.
|
- 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.
|
- Auto-install app update.
|
||||||
- Configurable interval to refresh entries from downloaded storage.
|
- Configurable interval to refresh entries from downloaded storage.
|
||||||
- Forked from SY so everything from SY.
|
- 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:
|
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)
|
* 8Muses (EroMuse)
|
||||||
* HBrowse
|
|
||||||
* Mangadex
|
* Mangadex
|
||||||
* NHentai
|
* NHentai
|
||||||
* Puruin
|
* 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 {
|
defaultConfig {
|
||||||
applicationId = "app.komikku"
|
applicationId = "app.komikku"
|
||||||
|
|
||||||
versionCode = 78
|
versionCode = 79
|
||||||
versionName = "1.13.6"
|
versionName = "1.13.6"
|
||||||
|
|
||||||
buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"")
|
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.GSSManager
|
||||||
-dontwarn org.ietf.jgss.GSSName
|
-dontwarn org.ietf.jgss.GSSName
|
||||||
-dontwarn org.ietf.jgss.Oid
|
-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" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
<!-- Deep link to add repos -->
|
<!-- Legacy deeplink to add extension store -->
|
||||||
<intent-filter android:label="@string/action_add_repo">
|
<intent-filter android:label="@string/action_addExtensionStore">
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
|
@ -84,6 +84,17 @@
|
||||||
<data android:host="add-repo" />
|
<data android:host="add-repo" />
|
||||||
</intent-filter>
|
</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 -->
|
<!-- Open backup files -->
|
||||||
<intent-filter android:label="@string/pref_restore_backup">
|
<intent-filter android:label="@string/pref_restore_backup">
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
@ -241,7 +252,7 @@
|
||||||
<service
|
<service
|
||||||
android:name=".extension.util.ExtensionInstallService"
|
android:name=".extension.util.ExtensionInstallService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="shortService" />
|
android:foregroundServiceType="dataSync" />
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
|
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
|
||||||
|
|
@ -333,22 +344,6 @@
|
||||||
|
|
||||||
<data android:pathPattern="/g/..*" />
|
<data android:pathPattern="/g/..*" />
|
||||||
</intent-filter>
|
</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 -->
|
<!-- Pururin -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
@ -363,21 +358,6 @@
|
||||||
|
|
||||||
<data android:pathPattern="/gallery/..*" />
|
<data android:pathPattern="/gallery/..*" />
|
||||||
</intent-filter>
|
</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 -->
|
<!-- Mangadex -->
|
||||||
<intent-filter android:autoVerify="true">
|
<intent-filter android:autoVerify="true">
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<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
|
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/all/NHentai;
|
||||||
Leu/kanade/tachiyomi/source/online/english/EightMuses;
|
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/Pururin;
|
||||||
Leu/kanade/tachiyomi/source/online/english/Tsumino;
|
|
||||||
Leu/kanade/tachiyomi/ui/base/activity/BaseActivity;
|
Leu/kanade/tachiyomi/ui/base/activity/BaseActivity;
|
||||||
HSPLeu/kanade/tachiyomi/ui/base/activity/BaseActivity;-><init>()V
|
HSPLeu/kanade/tachiyomi/ui/base/activity/BaseActivity;-><init>()V
|
||||||
PLeu/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.RefreshTracks
|
||||||
import eu.kanade.domain.track.interactor.SyncChapterProgressWithTrack
|
import eu.kanade.domain.track.interactor.SyncChapterProgressWithTrack
|
||||||
import eu.kanade.domain.track.interactor.TrackChapter
|
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.chapter.interactor.FilterChaptersForDownload
|
||||||
import mihon.domain.extensionrepo.interactor.CreateExtensionRepo
|
import mihon.domain.chapter.interactor.GetChaptersForLibraryUpdateDownload
|
||||||
import mihon.domain.extensionrepo.interactor.DeleteExtensionRepo
|
import mihon.domain.extension.interactor.AddExtensionStore
|
||||||
import mihon.domain.extensionrepo.interactor.GetExtensionRepo
|
import mihon.domain.extension.interactor.GetExtensionStoreCountAsFlow
|
||||||
import mihon.domain.extensionrepo.interactor.GetExtensionRepoCount
|
import mihon.domain.extension.interactor.GetExtensionStores
|
||||||
import mihon.domain.extensionrepo.interactor.ReplaceExtensionRepo
|
import mihon.domain.extension.interactor.RemoveExtensionStore
|
||||||
import mihon.domain.extensionrepo.interactor.UpdateExtensionRepo
|
import mihon.domain.extension.interactor.UpdateExtensionStores
|
||||||
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
|
import mihon.domain.extension.repository.ExtensionStoreRepository
|
||||||
import mihon.domain.extensionrepo.service.ExtensionRepoService
|
|
||||||
import mihon.domain.migration.usecases.MigrateMangaUseCase
|
import mihon.domain.migration.usecases.MigrateMangaUseCase
|
||||||
import mihon.domain.upcoming.interactor.GetUpcomingManga
|
import mihon.domain.upcoming.interactor.GetUpcomingManga
|
||||||
import tachiyomi.data.category.CategoryRepositoryImpl
|
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.SetSortModeForCategory
|
||||||
import tachiyomi.domain.category.interactor.UpdateCategory
|
import tachiyomi.domain.category.interactor.UpdateCategory
|
||||||
import tachiyomi.domain.category.repository.CategoryRepository
|
import tachiyomi.domain.category.repository.CategoryRepository
|
||||||
|
import tachiyomi.domain.chapter.interactor.GetBookmarkedChaptersByMangaId
|
||||||
import tachiyomi.domain.chapter.interactor.GetChapter
|
import tachiyomi.domain.chapter.interactor.GetChapter
|
||||||
import tachiyomi.domain.chapter.interactor.GetChapterByUrlAndMangaId
|
import tachiyomi.domain.chapter.interactor.GetChapterByUrlAndMangaId
|
||||||
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
|
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
|
||||||
|
|
@ -67,6 +68,7 @@ import tachiyomi.domain.chapter.interactor.UpdateChapter
|
||||||
import tachiyomi.domain.chapter.repository.ChapterRepository
|
import tachiyomi.domain.chapter.repository.ChapterRepository
|
||||||
import tachiyomi.domain.history.interactor.GetCrossMangaPageRateHistorySamples
|
import tachiyomi.domain.history.interactor.GetCrossMangaPageRateHistorySamples
|
||||||
import tachiyomi.domain.history.interactor.GetHistory
|
import tachiyomi.domain.history.interactor.GetHistory
|
||||||
|
import tachiyomi.domain.history.interactor.GetHistoryRevision
|
||||||
import tachiyomi.domain.history.interactor.GetNextChapters
|
import tachiyomi.domain.history.interactor.GetNextChapters
|
||||||
import tachiyomi.domain.history.interactor.GetReadDurationEntriesByMangaIds
|
import tachiyomi.domain.history.interactor.GetReadDurationEntriesByMangaIds
|
||||||
import tachiyomi.domain.history.interactor.GetTotalReadDuration
|
import tachiyomi.domain.history.interactor.GetTotalReadDuration
|
||||||
|
|
@ -163,6 +165,7 @@ class DomainModule : InjektModule {
|
||||||
addSingletonFactory<ChapterRepository> { ChapterRepositoryImpl(get()) }
|
addSingletonFactory<ChapterRepository> { ChapterRepositoryImpl(get()) }
|
||||||
addFactory { GetChapter(get()) }
|
addFactory { GetChapter(get()) }
|
||||||
addFactory { GetChaptersByMangaId(get()) }
|
addFactory { GetChaptersByMangaId(get()) }
|
||||||
|
addFactory { GetBookmarkedChaptersByMangaId(get(), get(), get()) }
|
||||||
addFactory { GetChapterByUrlAndMangaId(get()) }
|
addFactory { GetChapterByUrlAndMangaId(get()) }
|
||||||
addFactory { UpdateChapter(get()) }
|
addFactory { UpdateChapter(get()) }
|
||||||
addFactory { SetReadStatus(get(), get(), get(), get(), get()) }
|
addFactory { SetReadStatus(get(), get(), get(), get(), get()) }
|
||||||
|
|
@ -170,14 +173,16 @@ class DomainModule : InjektModule {
|
||||||
addFactory { SyncChaptersWithSource(get(), get(), get(), get(), get(), get(), get(), get(), get()) }
|
addFactory { SyncChaptersWithSource(get(), get(), get(), get(), get(), get(), get(), get(), get()) }
|
||||||
addFactory { GetAvailableScanlators(get()) }
|
addFactory { GetAvailableScanlators(get()) }
|
||||||
addFactory { FilterChaptersForDownload(get(), get(), get(), get()) }
|
addFactory { FilterChaptersForDownload(get(), get(), get(), get()) }
|
||||||
|
addFactory { GetChaptersForLibraryUpdateDownload(get(), get()) }
|
||||||
|
|
||||||
addSingletonFactory<HistoryRepository> { HistoryRepositoryImpl(get()) }
|
addSingletonFactory<HistoryRepository> { HistoryRepositoryImpl(get()) }
|
||||||
addFactory { GetHistory(get()) }
|
addFactory { GetHistory(get()) }
|
||||||
|
addFactory { GetHistoryRevision(get()) }
|
||||||
addFactory { UpsertHistory(get()) }
|
addFactory { UpsertHistory(get()) }
|
||||||
addFactory { RemoveHistory(get()) }
|
addFactory { RemoveHistory(get()) }
|
||||||
addFactory { GetTotalReadDuration(get()) }
|
addFactory { GetTotalReadDuration(get()) }
|
||||||
addFactory { GetReadDurationEntriesByMangaIds(get()) }
|
addFactory { GetReadDurationEntriesByMangaIds(get()) }
|
||||||
addFactory { GetCrossMangaPageRateHistorySamples(get()) }
|
addFactory { GetCrossMangaPageRateHistorySamples(get(), get()) }
|
||||||
|
|
||||||
addFactory { DeleteDownload(get(), get()) }
|
addFactory { DeleteDownload(get(), get()) }
|
||||||
|
|
||||||
|
|
@ -201,14 +206,14 @@ class DomainModule : InjektModule {
|
||||||
addFactory { ToggleSourcePin(get()) }
|
addFactory { ToggleSourcePin(get()) }
|
||||||
addFactory { TrustExtension(get(), get()) }
|
addFactory { TrustExtension(get(), get()) }
|
||||||
|
|
||||||
addSingletonFactory<ExtensionRepoRepository> { ExtensionRepoRepositoryImpl(get()) }
|
addSingletonFactory { ExtensionStoreService(get(), get(), get()) }
|
||||||
addFactory { ExtensionRepoService(get(), get()) }
|
addSingletonFactory<ExtensionStoreRepository> { ExtensionStoreRepositoryImpl(get(), get()) }
|
||||||
addFactory { GetExtensionRepo(get()) }
|
addFactory { AddExtensionStore(get()) }
|
||||||
addFactory { GetExtensionRepoCount(get()) }
|
addFactory { GetExtensionStoreCountAsFlow(get()) }
|
||||||
addFactory { CreateExtensionRepo(get(), get()) }
|
addFactory { GetExtensionStores(get()) }
|
||||||
addFactory { DeleteExtensionRepo(get()) }
|
addFactory { RemoveExtensionStore(get()) }
|
||||||
addFactory { ReplaceExtensionRepo(get()) }
|
addFactory { UpdateExtensionStores(get()) }
|
||||||
addFactory { UpdateExtensionRepo(get(), get()) }
|
|
||||||
addFactory { ToggleIncognito(get()) }
|
addFactory { ToggleIncognito(get()) }
|
||||||
addFactory { GetIncognitoState(get(), get(), get()) }
|
addFactory { GetIncognitoState(get(), get(), get()) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,11 +28,7 @@ class GetExtensionLanguages(
|
||||||
) { enabledLanguage, availableExtensions ->
|
) { enabledLanguage, availableExtensions ->
|
||||||
availableExtensions
|
availableExtensions
|
||||||
.flatMap { ext ->
|
.flatMap { ext ->
|
||||||
if (ext.sources.isEmpty()) {
|
ext.sources.map { it.lang }
|
||||||
listOf(ext.lang)
|
|
||||||
} else {
|
|
||||||
ext.sources.map { it.lang }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.distinct()
|
.distinct()
|
||||||
.sortedWith(
|
.sortedWith(
|
||||||
|
|
|
||||||
|
|
@ -50,9 +50,6 @@ class GetExtensionsByType(
|
||||||
(showNsfwSources || !extension.isNsfw)
|
(showNsfwSources || !extension.isNsfw)
|
||||||
}
|
}
|
||||||
.flatMap { ext ->
|
.flatMap { ext ->
|
||||||
if (ext.sources.isEmpty()) {
|
|
||||||
return@flatMap if (ext.lang in enabledLanguages) listOf(ext) else emptyList()
|
|
||||||
}
|
|
||||||
ext.sources.filter { it.lang in enabledLanguages }
|
ext.sources.filter { it.lang in enabledLanguages }
|
||||||
.map {
|
.map {
|
||||||
ext.copy(
|
ext.copy(
|
||||||
|
|
|
||||||
|
|
@ -4,21 +4,21 @@ import android.content.pm.PackageInfo
|
||||||
import androidx.core.content.pm.PackageInfoCompat
|
import androidx.core.content.pm.PackageInfoCompat
|
||||||
import eu.kanade.domain.source.service.SourcePreferences
|
import eu.kanade.domain.source.service.SourcePreferences
|
||||||
import eu.kanade.tachiyomi.util.system.isDebugBuildType
|
import eu.kanade.tachiyomi.util.system.isDebugBuildType
|
||||||
import mihon.domain.extensionrepo.interactor.CreateExtensionRepo
|
import mihon.domain.extension.model.KOMIKKU_SIGNATURE
|
||||||
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
|
import mihon.domain.extension.repository.ExtensionStoreRepository
|
||||||
import tachiyomi.core.common.preference.getAndSet
|
import tachiyomi.core.common.preference.getAndSet
|
||||||
|
|
||||||
class TrustExtension(
|
class TrustExtension(
|
||||||
private val extensionRepoRepository: ExtensionRepoRepository,
|
private val repository: ExtensionStoreRepository,
|
||||||
private val preferences: SourcePreferences,
|
private val preferences: SourcePreferences,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
suspend fun isTrusted(pkgInfo: PackageInfo, fingerprints: List<String>): Boolean {
|
suspend fun isTrusted(pkgInfo: PackageInfo, fingerprints: List<String>): Boolean {
|
||||||
// KMK -->
|
// KMK -->
|
||||||
if (isDebugBuildType) return true
|
if (isDebugBuildType) return true
|
||||||
if (fingerprints.contains(CreateExtensionRepo.KOMIKKU_SIGNATURE)) return true
|
if (fingerprints.contains(KOMIKKU_SIGNATURE)) return true
|
||||||
// KMK <--
|
// 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()}"
|
val key = "${pkgInfo.packageName}:${PackageInfoCompat.getLongVersionCode(pkgInfo)}:${fingerprints.last()}"
|
||||||
return trustedFingerprints.any { fingerprints.contains(it) } || key in preferences.trustedExtensions().get()
|
return trustedFingerprints.any { fingerprints.contains(it) } || key in preferences.trustedExtensions().get()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ import java.util.UUID
|
||||||
class SyncPreferences(
|
class SyncPreferences(
|
||||||
private val preferenceStore: PreferenceStore,
|
private val preferenceStore: PreferenceStore,
|
||||||
) {
|
) {
|
||||||
fun clientHost() = preferenceStore.getString("sync_client_host", "https://sync.tachiyomi.org")
|
fun clientHost() = preferenceStore.getString("connection_sync_client_host", "https://sync.tachiyomi.org")
|
||||||
fun clientAPIKey() = preferenceStore.getString("sync_client_api_key", "")
|
fun clientAPIKey() = preferenceStore.getString("connection_sync_client_api_key", "")
|
||||||
fun lastSyncTimestamp() = preferenceStore.getLong(Preference.appStateKey("last_sync_timestamp"), 0L)
|
fun lastSyncTimestamp() = preferenceStore.getLong(Preference.appStateKey("last_sync_timestamp"), 0L)
|
||||||
|
|
||||||
fun lastSyncEtag() = preferenceStore.getString("sync_etag", "")
|
fun lastSyncEtag() = preferenceStore.getString("sync_etag", "")
|
||||||
|
|
@ -20,19 +20,19 @@ class SyncPreferences(
|
||||||
fun syncService() = preferenceStore.getInt("sync_service", 0)
|
fun syncService() = preferenceStore.getInt("sync_service", 0)
|
||||||
|
|
||||||
// KMK -->
|
// KMK -->
|
||||||
fun webDavUrl() = preferenceStore.getString("webdav_url", "")
|
fun webDavUrl() = preferenceStore.getString("connection_webdav_url", "")
|
||||||
fun webDavUsername() = preferenceStore.getString("webdav_username", "")
|
fun webDavUsername() = preferenceStore.getString("connection_webdav_username", "")
|
||||||
fun webDavPassword() = preferenceStore.getString("webdav_password", "")
|
fun webDavPassword() = preferenceStore.getString("connection_webdav_password", "")
|
||||||
fun webDavFolder() = preferenceStore.getString("webdav_folder", "komikku")
|
fun webDavFolder() = preferenceStore.getString("connection_webdav_folder", "komikku")
|
||||||
// KMK <--
|
// KMK <--
|
||||||
|
|
||||||
fun googleDriveAccessToken() = preferenceStore.getString(
|
fun googleDriveAccessToken() = preferenceStore.getString(
|
||||||
Preference.appStateKey("google_drive_access_token"),
|
Preference.appStateKey("connection_google_drive_access_token"),
|
||||||
"",
|
"",
|
||||||
)
|
)
|
||||||
|
|
||||||
fun googleDriveRefreshToken() = preferenceStore.getString(
|
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 {
|
fun getSyncSettings(): SyncSettings {
|
||||||
return SyncSettings(
|
return SyncSettings(
|
||||||
libraryEntries = preferenceStore.getBoolean("library_entries", true).get(),
|
libraryEntries = preferenceStore.getBoolean("sync_library_entries", true).get(),
|
||||||
categories = preferenceStore.getBoolean("categories", true).get(),
|
categories = preferenceStore.getBoolean("sync_categories", true).get(),
|
||||||
chapters = preferenceStore.getBoolean("chapters", true).get(),
|
chapters = preferenceStore.getBoolean("sync_chapters", true).get(),
|
||||||
tracking = preferenceStore.getBoolean("tracking", true).get(),
|
tracking = preferenceStore.getBoolean("sync_tracking", true).get(),
|
||||||
history = preferenceStore.getBoolean("history", true).get(),
|
history = preferenceStore.getBoolean("sync_history", true).get(),
|
||||||
appSettings = preferenceStore.getBoolean("appSettings", true).get(),
|
appSettings = preferenceStore.getBoolean("sync_appSettings", true).get(),
|
||||||
extensionRepoSettings = preferenceStore.getBoolean("extensionRepoSettings", true).get(),
|
extensionStores = preferenceStore.getBoolean("sync_extensionStores", true).get(),
|
||||||
sourceSettings = preferenceStore.getBoolean("sourceSettings", true).get(),
|
sourceSettings = preferenceStore.getBoolean("sync_sourceSettings", true).get(),
|
||||||
privateSettings = preferenceStore.getBoolean("privateSettings", true).get(),
|
privateSettings = preferenceStore.getBoolean("sync_privateSettings", true).get(),
|
||||||
|
|
||||||
// SY -->
|
// SY -->
|
||||||
customInfo = preferenceStore.getBoolean("customInfo", true).get(),
|
customInfo = preferenceStore.getBoolean("sync_customInfo", true).get(),
|
||||||
readEntries = preferenceStore.getBoolean("readEntries", true).get(),
|
readEntries = preferenceStore.getBoolean("sync_readEntries", true).get(),
|
||||||
savedSearchesFeeds = preferenceStore.getBoolean("savedSearchesFeeds", true).get(),
|
savedSearchesFeeds = preferenceStore.getBoolean("sync_savedSearchesFeeds", true).get(),
|
||||||
// SY <--
|
// SY <--
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSyncSettings(syncSettings: SyncSettings) {
|
fun setSyncSettings(syncSettings: SyncSettings) {
|
||||||
preferenceStore.getBoolean("library_entries", true).set(syncSettings.libraryEntries)
|
preferenceStore.getBoolean("sync_library_entries", true).set(syncSettings.libraryEntries)
|
||||||
preferenceStore.getBoolean("categories", true).set(syncSettings.categories)
|
preferenceStore.getBoolean("sync_categories", true).set(syncSettings.categories)
|
||||||
preferenceStore.getBoolean("chapters", true).set(syncSettings.chapters)
|
preferenceStore.getBoolean("sync_chapters", true).set(syncSettings.chapters)
|
||||||
preferenceStore.getBoolean("tracking", true).set(syncSettings.tracking)
|
preferenceStore.getBoolean("sync_tracking", true).set(syncSettings.tracking)
|
||||||
preferenceStore.getBoolean("history", true).set(syncSettings.history)
|
preferenceStore.getBoolean("sync_history", true).set(syncSettings.history)
|
||||||
preferenceStore.getBoolean("appSettings", true).set(syncSettings.appSettings)
|
preferenceStore.getBoolean("sync_appSettings", true).set(syncSettings.appSettings)
|
||||||
preferenceStore.getBoolean("extensionRepoSettings", true).set(syncSettings.extensionRepoSettings)
|
preferenceStore.getBoolean("sync_extensionStores", true).set(syncSettings.extensionStores)
|
||||||
preferenceStore.getBoolean("sourceSettings", true).set(syncSettings.sourceSettings)
|
preferenceStore.getBoolean("sync_sourceSettings", true).set(syncSettings.sourceSettings)
|
||||||
preferenceStore.getBoolean("privateSettings", true).set(syncSettings.privateSettings)
|
preferenceStore.getBoolean("sync_privateSettings", true).set(syncSettings.privateSettings)
|
||||||
|
|
||||||
// SY -->
|
// SY -->
|
||||||
preferenceStore.getBoolean("customInfo", true).set(syncSettings.customInfo)
|
preferenceStore.getBoolean("sync_customInfo", true).set(syncSettings.customInfo)
|
||||||
preferenceStore.getBoolean("readEntries", true).set(syncSettings.readEntries)
|
preferenceStore.getBoolean("sync_readEntries", true).set(syncSettings.readEntries)
|
||||||
preferenceStore.getBoolean("savedSearchesFeeds", true).set(syncSettings.savedSearchesFeeds)
|
preferenceStore.getBoolean("sync_savedSearchesFeeds", true).set(syncSettings.savedSearchesFeeds)
|
||||||
// SY <--
|
// SY <--
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ data class SyncSettings(
|
||||||
val tracking: Boolean = true,
|
val tracking: Boolean = true,
|
||||||
val history: Boolean = true,
|
val history: Boolean = true,
|
||||||
val appSettings: Boolean = true,
|
val appSettings: Boolean = true,
|
||||||
val extensionRepoSettings: Boolean = true,
|
val extensionStores: Boolean = true,
|
||||||
val sourceSettings: Boolean = true,
|
val sourceSettings: Boolean = true,
|
||||||
val privateSettings: Boolean = false,
|
val privateSettings: Boolean = false,
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -86,12 +86,12 @@ fun ExtensionDetailsScreen(
|
||||||
val uriHandler = LocalUriHandler.current
|
val uriHandler = LocalUriHandler.current
|
||||||
val url = remember(state.extension) {
|
val url = remember(state.extension) {
|
||||||
val regex = """https://raw.githubusercontent.com/(.+?)/(.+?)/.+""".toRegex()
|
val regex = """https://raw.githubusercontent.com/(.+?)/(.+?)/.+""".toRegex()
|
||||||
regex.find(state.extension?.repoUrl.orEmpty())
|
regex.find(state.extension?.store?.indexUrl.orEmpty())
|
||||||
?.let {
|
?.let {
|
||||||
val (user, repo) = it.destructured
|
val (user, repo) = it.destructured
|
||||||
"https://github.com/$user/$repo"
|
"https://github.com/$user/$repo"
|
||||||
}
|
}
|
||||||
?: state.extension?.repoUrl
|
?: state.extension?.store?.indexUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
|
@ -270,14 +270,17 @@ private fun DetailsHeader(
|
||||||
|
|
||||||
if (extension is Extension.Installed) {
|
if (extension is Extension.Installed) {
|
||||||
append("\n\n")
|
append("\n\n")
|
||||||
append(
|
appendLine(
|
||||||
"""
|
"""
|
||||||
Update available: ${extension.hasUpdate}
|
Update available: ${extension.hasUpdate}
|
||||||
Obsolete: ${extension.isObsolete}
|
Orphaned: ${extension.isObsolete}
|
||||||
Shared: ${extension.isShared}
|
Shared: ${extension.isShared}
|
||||||
Repository: ${extension.repoUrl}
|
|
||||||
""".trimIndent(),
|
""".trimIndent(),
|
||||||
)
|
)
|
||||||
|
val store = extension.store
|
||||||
|
if (store != null) {
|
||||||
|
append("Repository: ${store.indexUrl}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
context.copyToClipboard("Extension Debug information", extDebugInfo)
|
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.browse.components.ExtensionIcon
|
||||||
import eu.kanade.presentation.components.WarningBanner
|
import eu.kanade.presentation.components.WarningBanner
|
||||||
import eu.kanade.presentation.manga.components.DotSeparatorNoSpaceText
|
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.animateItemFastScroll
|
||||||
import eu.kanade.presentation.util.rememberRequestPackageInstallsPermissionState
|
import eu.kanade.presentation.util.rememberRequestPackageInstallsPermissionState
|
||||||
import eu.kanade.tachiyomi.extension.model.Extension
|
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.LocaleHelper
|
||||||
import eu.kanade.tachiyomi.util.system.launchRequestPackageInstallsPermission
|
import eu.kanade.tachiyomi.util.system.launchRequestPackageInstallsPermission
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
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.MR
|
||||||
import tachiyomi.i18n.kmk.KMR
|
import tachiyomi.i18n.kmk.KMR
|
||||||
import tachiyomi.i18n.sy.SYMR
|
import tachiyomi.i18n.sy.SYMR
|
||||||
|
|
@ -110,9 +112,9 @@ fun ExtensionScreen(
|
||||||
modifier = Modifier.padding(contentPadding),
|
modifier = Modifier.padding(contentPadding),
|
||||||
actions = persistentListOf(
|
actions = persistentListOf(
|
||||||
EmptyScreenAction(
|
EmptyScreenAction(
|
||||||
stringRes = MR.strings.label_extension_repos,
|
stringRes = MR.strings.extensionStores,
|
||||||
icon = Icons.Outlined.Settings,
|
icon = Icons.Outlined.Settings,
|
||||||
onClick = { navigator.push(ExtensionReposScreen()) },
|
onClick = { navigator.push(ExtensionStoresScreen()) },
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -195,9 +197,9 @@ private fun ExtensionContent(
|
||||||
// KMK -->
|
// KMK -->
|
||||||
KMR.strings.extensions_page_more -> {
|
KMR.strings.extensions_page_more -> {
|
||||||
{
|
{
|
||||||
Button(onClick = { navigator?.push(ExtensionReposScreen()) }) {
|
Button(onClick = { navigator?.push(ExtensionStoresScreen()) }) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(MR.strings.action_add_repo),
|
text = stringResource(MR.strings.action_addExtensionStore),
|
||||||
style = LocalTextStyle.current.copy(
|
style = LocalTextStyle.current.copy(
|
||||||
color = MaterialTheme.colorScheme.onPrimary,
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
),
|
),
|
||||||
|
|
@ -401,7 +403,7 @@ private fun ExtensionItemContent(
|
||||||
}
|
}
|
||||||
|
|
||||||
// KMK -->
|
// KMK -->
|
||||||
Text(text = extension.repoName?.let { "@$it" } ?: "(?)")
|
Text(text = extension.storeName?.let { "@$it" } ?: "(?)")
|
||||||
// KMK <--
|
// KMK <--
|
||||||
|
|
||||||
val warning = when {
|
val warning = when {
|
||||||
|
|
@ -604,11 +606,11 @@ private fun ExtensionItemContentPreview() {
|
||||||
libVersion = 1.0,
|
libVersion = 1.0,
|
||||||
isNsfw = true,
|
isNsfw = true,
|
||||||
signatureHash = "900000",
|
signatureHash = "900000",
|
||||||
repoName = "Repository",
|
storeName = "Repository",
|
||||||
sources = emptyList(),
|
sources = emptyList(),
|
||||||
apkName = "Test",
|
apkUrl = "Test",
|
||||||
iconUrl = "",
|
iconUrl = "",
|
||||||
repoUrl = "",
|
store = ExtensionStore("https://repo", "Komikku", "", KOMIKKU_SIGNATURE, ExtensionStore.Contact("", ""), false),
|
||||||
)
|
)
|
||||||
val extInstalled = Extension.Installed(
|
val extInstalled = Extension.Installed(
|
||||||
name = "Tachiyomi",
|
name = "Tachiyomi",
|
||||||
|
|
@ -619,9 +621,9 @@ private fun ExtensionItemContentPreview() {
|
||||||
libVersion = 1.0,
|
libVersion = 1.0,
|
||||||
isNsfw = true,
|
isNsfw = true,
|
||||||
signatureHash = "900000",
|
signatureHash = "900000",
|
||||||
repoName = "Repository",
|
storeName = "Repository",
|
||||||
sources = emptyList(),
|
sources = emptyList(),
|
||||||
repoUrl = "",
|
store = ExtensionStore("https://repo", "Komikku", "", KOMIKKU_SIGNATURE, ExtensionStore.Contact("", ""), false),
|
||||||
pkgFactory = null,
|
pkgFactory = null,
|
||||||
icon = null,
|
icon = null,
|
||||||
hasUpdate = false,
|
hasUpdate = false,
|
||||||
|
|
@ -638,12 +640,12 @@ private fun ExtensionItemContentPreview() {
|
||||||
libVersion = 1.0,
|
libVersion = 1.0,
|
||||||
isNsfw = true,
|
isNsfw = true,
|
||||||
signatureHash = "900000",
|
signatureHash = "900000",
|
||||||
repoName = "Repository",
|
storeName = "Repository",
|
||||||
)
|
)
|
||||||
Column {
|
Column {
|
||||||
ExtensionItemContent(
|
ExtensionItemContent(
|
||||||
extension = extAvail.copy(
|
extension = extAvail.copy(
|
||||||
repoName = "Repository extensions minion multiple languages various sources",
|
storeName = "Repository extensions minion multiple languages various sources",
|
||||||
),
|
),
|
||||||
installStep = InstallStep.Idle,
|
installStep = InstallStep.Idle,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -336,47 +336,46 @@ fun SourceFeedToolbar(
|
||||||
// KMK -->
|
// KMK -->
|
||||||
actions = {
|
actions = {
|
||||||
AppBarActions(
|
AppBarActions(
|
||||||
actions = persistentListOf<AppBar.AppBarAction>().builder()
|
actions = persistentListOf<AppBar.AppBarAction>().builder().apply {
|
||||||
.apply {
|
add(bulkSelectionButton(isRunning, toggleSelectionMode))
|
||||||
add(bulkSelectionButton(isRunning, toggleSelectionMode))
|
|
||||||
|
|
||||||
onWebViewClick?.let {
|
onWebViewClick?.let { func ->
|
||||||
add(
|
|
||||||
AppBar.Action(
|
|
||||||
title = stringResource(MR.strings.action_web_view),
|
|
||||||
onClick = { onWebViewClick() },
|
|
||||||
icon = Icons.Outlined.Public,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// KMK -->
|
|
||||||
add(
|
add(
|
||||||
AppBar.OverflowAction(
|
AppBar.Action(
|
||||||
title = stringResource(MR.strings.pref_incognito_mode),
|
title = stringResource(MR.strings.action_web_view),
|
||||||
onClick = onToggleIncognito,
|
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(),
|
.build(),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -209,7 +209,7 @@ fun BrowseSourceEHentaiListItem(
|
||||||
MangaCoverHide.Book(
|
MangaCoverHide.Book(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxHeight(),
|
.fillMaxHeight(),
|
||||||
bgColor = bgColor ?: (MaterialTheme.colorScheme.surface.takeIf { isSelected }),
|
bgColor = bgColor ?: MaterialTheme.colorScheme.surface.takeIf { isSelected },
|
||||||
tint = onBgColor,
|
tint = onBgColor,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -219,7 +219,7 @@ fun BrowseSourceEHentaiListItem(
|
||||||
.fillMaxHeight(),
|
.fillMaxHeight(),
|
||||||
// KMK -->
|
// KMK -->
|
||||||
alpha = if (isSelected) GRID_SELECTED_COVER_ALPHA else coverAlpha,
|
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,
|
tint = onBgColor,
|
||||||
// KMK <--
|
// KMK <--
|
||||||
data = coverData,
|
data = coverData,
|
||||||
|
|
|
||||||
|
|
@ -39,27 +39,26 @@ fun BrowseSourceSimpleToolbar(
|
||||||
var selectingDisplayMode by remember { mutableStateOf(false) }
|
var selectingDisplayMode by remember { mutableStateOf(false) }
|
||||||
// KMK -->
|
// KMK -->
|
||||||
AppBarActions(
|
AppBarActions(
|
||||||
actions = persistentListOf<AppBar.AppBarAction>().builder()
|
actions = persistentListOf<AppBar.AppBarAction>().builder().apply {
|
||||||
.apply {
|
displayMode?.let { mode ->
|
||||||
displayMode?.let {
|
add(
|
||||||
add(
|
AppBar.Action(
|
||||||
AppBar.Action(
|
title = stringResource(MR.strings.action_display_mode),
|
||||||
title = stringResource(MR.strings.action_display_mode),
|
icon = if (mode == LibraryDisplayMode.List) {
|
||||||
icon = if (displayMode == LibraryDisplayMode.List) {
|
Icons.AutoMirrored.Filled.ViewList
|
||||||
Icons.AutoMirrored.Filled.ViewList
|
} else {
|
||||||
} else {
|
Icons.Filled.ViewModule
|
||||||
Icons.Filled.ViewModule
|
},
|
||||||
},
|
onClick = { selectingDisplayMode = true },
|
||||||
onClick = { selectingDisplayMode = true },
|
),
|
||||||
),
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
toggleSelectionMode?.let {
|
|
||||||
add(
|
|
||||||
bulkSelectionButton(isRunning, toggleSelectionMode),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
toggleSelectionMode?.let { mode ->
|
||||||
|
add(
|
||||||
|
bulkSelectionButton(isRunning, mode),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
// KMK <--
|
// KMK <--
|
||||||
|
|
|
||||||
|
|
@ -30,53 +30,51 @@ fun BulkSelectionToolbar(
|
||||||
titleContent = { Text(text = "$selectedCount") },
|
titleContent = { Text(text = "$selectedCount") },
|
||||||
actions = {
|
actions = {
|
||||||
AppBarActions(
|
AppBarActions(
|
||||||
actions = persistentListOf<AppBar.AppBarAction>()
|
actions = persistentListOf<AppBar.AppBarAction>().builder().apply {
|
||||||
.builder()
|
if (onSelectAll != null) {
|
||||||
.apply {
|
add(
|
||||||
if (onSelectAll != null) {
|
AppBar.Action(
|
||||||
add(
|
title = stringResource(MR.strings.action_select_all),
|
||||||
AppBar.Action(
|
icon = Icons.Filled.SelectAll,
|
||||||
title = stringResource(MR.strings.action_select_all),
|
onClick = onSelectAll,
|
||||||
icon = Icons.Filled.SelectAll,
|
),
|
||||||
onClick = onSelectAll,
|
)
|
||||||
),
|
}
|
||||||
)
|
if (onReverseSelection != null) {
|
||||||
}
|
add(
|
||||||
if (onReverseSelection != null) {
|
AppBar.Action(
|
||||||
add(
|
title = stringResource(MR.strings.action_select_inverse),
|
||||||
AppBar.Action(
|
icon = Icons.Outlined.FlipToBack,
|
||||||
title = stringResource(MR.strings.action_select_inverse),
|
onClick = onReverseSelection,
|
||||||
icon = Icons.Outlined.FlipToBack,
|
),
|
||||||
onClick = onReverseSelection,
|
)
|
||||||
),
|
}
|
||||||
)
|
if (isRunning) {
|
||||||
}
|
add(
|
||||||
if (isRunning) {
|
AppBar.ActionCompose(
|
||||||
add(
|
title = stringResource(KMR.strings.action_bulk_select),
|
||||||
AppBar.ActionCompose(
|
) {
|
||||||
title = stringResource(KMR.strings.action_bulk_select),
|
CircularProgressIndicator(
|
||||||
) {
|
modifier = Modifier
|
||||||
CircularProgressIndicator(
|
.size(24.dp),
|
||||||
modifier = Modifier
|
strokeWidth = 2.dp,
|
||||||
.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(
|
}.build(),
|
||||||
title = stringResource(MR.strings.add_to_library),
|
|
||||||
icon = Icons.Filled.Favorite,
|
|
||||||
onClick = {
|
|
||||||
if (selectedCount > 0) {
|
|
||||||
onChangeCategoryClick()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}.build(),
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
isActionMode = true,
|
isActionMode = true,
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ private fun DownloadDropdownMenuItems(
|
||||||
DownloadAction.NEXT_10_CHAPTERS to pluralStringResource(MR.plurals.download_amount, 10, 10),
|
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.NEXT_25_CHAPTERS to pluralStringResource(MR.plurals.download_amount, 25, 25),
|
||||||
DownloadAction.UNREAD_CHAPTERS to stringResource(MR.strings.download_unread),
|
DownloadAction.UNREAD_CHAPTERS to stringResource(MR.strings.download_unread),
|
||||||
|
DownloadAction.BOOKMARKED_CHAPTERS to stringResource(MR.strings.download_bookmarked),
|
||||||
)
|
)
|
||||||
|
|
||||||
options.forEach { (downloadAction, string) ->
|
options.forEach { (downloadAction, string) ->
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ fun MangaCompactGridItem(
|
||||||
MangaCoverHide.Book(
|
MangaCoverHide.Book(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
bgColor = bgColor ?: (MaterialTheme.colorScheme.surface.takeIf { isSelected }),
|
bgColor = bgColor ?: MaterialTheme.colorScheme.surface.takeIf { isSelected },
|
||||||
tint = onBgColor,
|
tint = onBgColor,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -119,7 +119,7 @@ fun MangaCompactGridItem(
|
||||||
data = coverData,
|
data = coverData,
|
||||||
// KMK -->
|
// KMK -->
|
||||||
alpha = if (isSelected) GRID_SELECTED_COVER_ALPHA else coverAlpha,
|
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,
|
tint = onBgColor,
|
||||||
// KMK <--
|
// KMK <--
|
||||||
)
|
)
|
||||||
|
|
@ -241,7 +241,7 @@ fun MangaComfortableGridItem(
|
||||||
MangaCoverHide.Book(
|
MangaCoverHide.Book(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
bgColor = bgColor ?: (MaterialTheme.colorScheme.surface.takeIf { isSelected }),
|
bgColor = bgColor ?: MaterialTheme.colorScheme.surface.takeIf { isSelected },
|
||||||
tint = onBgColor,
|
tint = onBgColor,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -255,7 +255,7 @@ fun MangaComfortableGridItem(
|
||||||
data = coverData,
|
data = coverData,
|
||||||
// KMK -->
|
// KMK -->
|
||||||
alpha = if (isSelected) GRID_SELECTED_COVER_ALPHA else coverAlpha,
|
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,
|
tint = onBgColor,
|
||||||
onCoverLoaded = { _, result ->
|
onCoverLoaded = { _, result ->
|
||||||
val image = result.result.image
|
val image = result.result.image
|
||||||
|
|
@ -274,7 +274,7 @@ fun MangaComfortableGridItem(
|
||||||
data = coverData,
|
data = coverData,
|
||||||
// KMK -->
|
// KMK -->
|
||||||
alpha = if (isSelected) GRID_SELECTED_COVER_ALPHA else coverAlpha,
|
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,
|
tint = onBgColor,
|
||||||
onCoverLoaded = { _, result ->
|
onCoverLoaded = { _, result ->
|
||||||
val image = result.result.image
|
val image = result.result.image
|
||||||
|
|
@ -461,7 +461,7 @@ fun MangaListItem(
|
||||||
MangaCoverHide.Square(
|
MangaCoverHide.Square(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxHeight(),
|
.fillMaxHeight(),
|
||||||
bgColor = bgColor ?: (MaterialTheme.colorScheme.surface.takeIf { isSelected }),
|
bgColor = bgColor ?: MaterialTheme.colorScheme.surface.takeIf { isSelected },
|
||||||
tint = onBgColor,
|
tint = onBgColor,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -475,7 +475,7 @@ fun MangaListItem(
|
||||||
data = coverData,
|
data = coverData,
|
||||||
// KMK -->
|
// KMK -->
|
||||||
alpha = coverAlpha,
|
alpha = coverAlpha,
|
||||||
bgColor = bgColor ?: (MaterialTheme.colorScheme.surface.takeIf { isSelected }),
|
bgColor = bgColor ?: MaterialTheme.colorScheme.surface.takeIf { isSelected },
|
||||||
tint = onBgColor,
|
tint = onBgColor,
|
||||||
size = MangaCover.Size.Big,
|
size = MangaCover.Size.Big,
|
||||||
// KMK <--
|
// KMK <--
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ fun LibraryUpdateErrorScreen(
|
||||||
onInvertSelection: () -> Unit,
|
onInvertSelection: () -> Unit,
|
||||||
onErrorsDelete: () -> Unit,
|
onErrorsDelete: () -> Unit,
|
||||||
onErrorDelete: (Long) -> Unit,
|
onErrorDelete: (Long) -> Unit,
|
||||||
onErrorSelected: (LibraryUpdateErrorItem, Boolean, Boolean, Boolean) -> Unit,
|
onErrorSelected: (LibraryUpdateErrorItem, Boolean, Boolean) -> Unit,
|
||||||
navigateUp: () -> Unit,
|
navigateUp: () -> Unit,
|
||||||
) {
|
) {
|
||||||
BackHandler(enabled = state.selectionMode, onBack = { onSelectAll(false) })
|
BackHandler(enabled = state.selectionMode, onBack = { onSelectAll(false) })
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ import tachiyomi.presentation.core.util.selectedBackground
|
||||||
internal fun LazyListScope.libraryUpdateErrorUiItems(
|
internal fun LazyListScope.libraryUpdateErrorUiItems(
|
||||||
uiModels: List<LibraryUpdateErrorUiModel>,
|
uiModels: List<LibraryUpdateErrorUiModel>,
|
||||||
selectionMode: Boolean,
|
selectionMode: Boolean,
|
||||||
onErrorSelected: (LibraryUpdateErrorItem, Boolean, Boolean, Boolean) -> Unit,
|
onErrorSelected: (LibraryUpdateErrorItem, Boolean, Boolean) -> Unit,
|
||||||
onClick: (LibraryUpdateErrorItem) -> Unit,
|
onClick: (LibraryUpdateErrorItem) -> Unit,
|
||||||
onClickCover: (LibraryUpdateErrorItem) -> Unit,
|
onClickCover: (LibraryUpdateErrorItem) -> Unit,
|
||||||
onDelete: (Long) -> Unit,
|
onDelete: (Long) -> Unit,
|
||||||
|
|
@ -81,7 +81,6 @@ internal fun LazyListScope.libraryUpdateErrorUiItems(
|
||||||
selectionMode -> onErrorSelected(
|
selectionMode -> onErrorSelected(
|
||||||
libraryUpdateErrorItem,
|
libraryUpdateErrorItem,
|
||||||
!libraryUpdateErrorItem.selected,
|
!libraryUpdateErrorItem.selected,
|
||||||
true,
|
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -93,7 +92,6 @@ internal fun LazyListScope.libraryUpdateErrorUiItems(
|
||||||
libraryUpdateErrorItem,
|
libraryUpdateErrorItem,
|
||||||
!libraryUpdateErrorItem.selected,
|
!libraryUpdateErrorItem.selected,
|
||||||
true,
|
true,
|
||||||
true,
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onClickCover = { onClickCover(libraryUpdateErrorItem) }.takeIf { !selectionMode },
|
onClickCover = { onClickCover(libraryUpdateErrorItem) }.takeIf { !selectionMode },
|
||||||
|
|
@ -144,8 +142,8 @@ private fun LibraryUpdateErrorUiItem(
|
||||||
.combinedClickable(
|
.combinedClickable(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
onLongClick = {
|
onLongClick = {
|
||||||
onLongClick()
|
|
||||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
|
onLongClick()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.padding(horizontal = MaterialTheme.padding.medium),
|
.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.MangaDex
|
||||||
import eu.kanade.tachiyomi.source.online.all.NHentai
|
import eu.kanade.tachiyomi.source.online.all.NHentai
|
||||||
import eu.kanade.tachiyomi.source.online.english.EightMuses
|
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.Pururin
|
||||||
import eu.kanade.tachiyomi.source.online.english.Tsumino
|
|
||||||
import eu.kanade.tachiyomi.ui.manga.ChapterList
|
import eu.kanade.tachiyomi.ui.manga.ChapterList
|
||||||
import eu.kanade.tachiyomi.ui.manga.MangaScreenModel
|
import eu.kanade.tachiyomi.ui.manga.MangaScreenModel
|
||||||
import eu.kanade.tachiyomi.ui.manga.MergedMangaData
|
import eu.kanade.tachiyomi.ui.manga.MergedMangaData
|
||||||
|
|
@ -108,12 +106,10 @@ import exh.source.getMainSource
|
||||||
import exh.source.isEhBasedManga
|
import exh.source.isEhBasedManga
|
||||||
import exh.ui.metadata.adapters.EHentaiDescription
|
import exh.ui.metadata.adapters.EHentaiDescription
|
||||||
import exh.ui.metadata.adapters.EightMusesDescription
|
import exh.ui.metadata.adapters.EightMusesDescription
|
||||||
import exh.ui.metadata.adapters.HBrowseDescription
|
|
||||||
import exh.ui.metadata.adapters.LanraragiDescription
|
import exh.ui.metadata.adapters.LanraragiDescription
|
||||||
import exh.ui.metadata.adapters.MangaDexDescription
|
import exh.ui.metadata.adapters.MangaDexDescription
|
||||||
import exh.ui.metadata.adapters.NHentaiDescription
|
import exh.ui.metadata.adapters.NHentaiDescription
|
||||||
import exh.ui.metadata.adapters.PururinDescription
|
import exh.ui.metadata.adapters.PururinDescription
|
||||||
import exh.ui.metadata.adapters.TsuminoDescription
|
|
||||||
import tachiyomi.domain.chapter.model.Chapter
|
import tachiyomi.domain.chapter.model.Chapter
|
||||||
import tachiyomi.domain.chapter.service.missingChaptersCount
|
import tachiyomi.domain.chapter.service.missingChaptersCount
|
||||||
import tachiyomi.domain.library.service.LibraryPreferences
|
import tachiyomi.domain.library.service.LibraryPreferences
|
||||||
|
|
@ -194,7 +190,7 @@ fun MangaScreen(
|
||||||
onChapterSwipe: (ChapterList.Item, LibraryPreferences.ChapterSwipeAction) -> Unit,
|
onChapterSwipe: (ChapterList.Item, LibraryPreferences.ChapterSwipeAction) -> Unit,
|
||||||
|
|
||||||
// Chapter selection
|
// Chapter selection
|
||||||
onChapterSelected: (ChapterList.Item, Boolean, Boolean, Boolean) -> Unit,
|
onChapterSelected: (ChapterList.Item, Boolean, Boolean) -> Unit,
|
||||||
onAllChapterSelected: (Boolean) -> Unit,
|
onAllChapterSelected: (Boolean) -> Unit,
|
||||||
onInvertSelection: () -> Unit,
|
onInvertSelection: () -> Unit,
|
||||||
|
|
||||||
|
|
@ -404,7 +400,7 @@ private fun MangaScreenSmallImpl(
|
||||||
onChapterSwipe: (ChapterList.Item, LibraryPreferences.ChapterSwipeAction) -> Unit,
|
onChapterSwipe: (ChapterList.Item, LibraryPreferences.ChapterSwipeAction) -> Unit,
|
||||||
|
|
||||||
// Chapter selection
|
// Chapter selection
|
||||||
onChapterSelected: (ChapterList.Item, Boolean, Boolean, Boolean) -> Unit,
|
onChapterSelected: (ChapterList.Item, Boolean, Boolean) -> Unit,
|
||||||
onAllChapterSelected: (Boolean) -> Unit,
|
onAllChapterSelected: (Boolean) -> Unit,
|
||||||
onInvertSelection: () -> Unit,
|
onInvertSelection: () -> Unit,
|
||||||
|
|
||||||
|
|
@ -868,7 +864,7 @@ private fun MangaScreenLargeImpl(
|
||||||
onChapterSwipe: (ChapterList.Item, LibraryPreferences.ChapterSwipeAction) -> Unit,
|
onChapterSwipe: (ChapterList.Item, LibraryPreferences.ChapterSwipeAction) -> Unit,
|
||||||
|
|
||||||
// Chapter selection
|
// Chapter selection
|
||||||
onChapterSelected: (ChapterList.Item, Boolean, Boolean, Boolean) -> Unit,
|
onChapterSelected: (ChapterList.Item, Boolean, Boolean) -> Unit,
|
||||||
onAllChapterSelected: (Boolean) -> Unit,
|
onAllChapterSelected: (Boolean) -> Unit,
|
||||||
onInvertSelection: () -> Unit,
|
onInvertSelection: () -> Unit,
|
||||||
|
|
||||||
|
|
@ -1307,7 +1303,7 @@ private fun LazyListScope.sharedChapterItems(
|
||||||
// SY <--
|
// SY <--
|
||||||
onChapterClicked: (Chapter) -> Unit,
|
onChapterClicked: (Chapter) -> Unit,
|
||||||
onDownloadChapter: ((List<ChapterList.Item>, ChapterDownloadAction) -> 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,
|
onChapterSwipe: (ChapterList.Item, LibraryPreferences.ChapterSwipeAction) -> Unit,
|
||||||
) {
|
) {
|
||||||
items(
|
items(
|
||||||
|
|
@ -1376,14 +1372,14 @@ private fun LazyListScope.sharedChapterItems(
|
||||||
chapterSwipeStartAction = chapterSwipeStartAction,
|
chapterSwipeStartAction = chapterSwipeStartAction,
|
||||||
chapterSwipeEndAction = chapterSwipeEndAction,
|
chapterSwipeEndAction = chapterSwipeEndAction,
|
||||||
onLongClick = {
|
onLongClick = {
|
||||||
onChapterSelected(item, !item.selected, true, true)
|
|
||||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
|
onChapterSelected(item, !item.selected, true)
|
||||||
},
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
onChapterItemClick(
|
onChapterItemClick(
|
||||||
chapterItem = item,
|
chapterItem = item,
|
||||||
isAnyChapterSelected = isAnyChapterSelected,
|
isAnyChapterSelected = isAnyChapterSelected,
|
||||||
onToggleSelection = { onChapterSelected(item, !item.selected, true, false) },
|
onToggleSelection = { onChapterSelected(item, !item.selected, false) },
|
||||||
onChapterClicked = onChapterClicked,
|
onChapterClicked = onChapterClicked,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -1438,15 +1434,9 @@ fun metadataDescription(source: Source): MetadataDescriptionComposable? {
|
||||||
is EightMuses -> { state, openMetadataViewer, _ ->
|
is EightMuses -> { state, openMetadataViewer, _ ->
|
||||||
EightMusesDescription(state, openMetadataViewer)
|
EightMusesDescription(state, openMetadataViewer)
|
||||||
}
|
}
|
||||||
is HBrowse -> { state, openMetadataViewer, _ ->
|
|
||||||
HBrowseDescription(state, openMetadataViewer)
|
|
||||||
}
|
|
||||||
is Pururin -> { state, openMetadataViewer, _ ->
|
is Pururin -> { state, openMetadataViewer, _ ->
|
||||||
PururinDescription(state, openMetadataViewer)
|
PururinDescription(state, openMetadataViewer)
|
||||||
}
|
}
|
||||||
is Tsumino -> { state, openMetadataViewer, _ ->
|
|
||||||
TsuminoDescription(state, openMetadataViewer)
|
|
||||||
}
|
|
||||||
is Lanraragi -> { state, openMetadataViewer, _ ->
|
is Lanraragi -> { state, openMetadataViewer, _ ->
|
||||||
LanraragiDescription(state, openMetadataViewer)
|
LanraragiDescription(state, openMetadataViewer)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ enum class DownloadAction {
|
||||||
NEXT_10_CHAPTERS,
|
NEXT_10_CHAPTERS,
|
||||||
NEXT_25_CHAPTERS,
|
NEXT_25_CHAPTERS,
|
||||||
UNREAD_CHAPTERS,
|
UNREAD_CHAPTERS,
|
||||||
|
BOOKMARKED_CHAPTERS,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class EditCoverAction {
|
enum class EditCoverAction {
|
||||||
|
|
|
||||||
|
|
@ -43,8 +43,8 @@ fun BaseMangaListItem(
|
||||||
onClick = onClickItem,
|
onClick = onClickItem,
|
||||||
// KMK -->
|
// KMK -->
|
||||||
onLongClick = {
|
onLongClick = {
|
||||||
onLongClick()
|
|
||||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
|
onLongClick()
|
||||||
},
|
},
|
||||||
// KMK <--
|
// KMK <--
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,11 @@ import eu.kanade.core.preference.asState
|
||||||
import eu.kanade.domain.source.service.SourcePreferences
|
import eu.kanade.domain.source.service.SourcePreferences
|
||||||
import eu.kanade.domain.ui.UiPreferences
|
import eu.kanade.domain.ui.UiPreferences
|
||||||
import eu.kanade.presentation.more.settings.Preference
|
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.ui.category.sources.SourceCategoryScreen
|
||||||
import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.authenticate
|
import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.authenticate
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
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.core.common.i18n.stringResource
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
import tachiyomi.i18n.kmk.KMR
|
import tachiyomi.i18n.kmk.KMR
|
||||||
|
|
@ -43,9 +43,9 @@ object SettingsBrowseScreen : SearchableSettings {
|
||||||
val navigator = LocalNavigator.currentOrThrow
|
val navigator = LocalNavigator.currentOrThrow
|
||||||
|
|
||||||
val sourcePreferences = remember { Injekt.get<SourcePreferences>() }
|
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 -->
|
// SY -->
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
@ -142,10 +142,10 @@ object SettingsBrowseScreen : SearchableSettings {
|
||||||
title = stringResource(MR.strings.pref_hide_in_library_items),
|
title = stringResource(MR.strings.pref_hide_in_library_items),
|
||||||
),
|
),
|
||||||
Preference.PreferenceItem.TextPreference(
|
Preference.PreferenceItem.TextPreference(
|
||||||
title = stringResource(MR.strings.label_extension_repos),
|
title = stringResource(MR.strings.extensionStores),
|
||||||
subtitle = pluralStringResource(MR.plurals.num_repos, reposCount, reposCount),
|
subtitle = pluralStringResource(MR.plurals.num_repos, reposCount.toInt(), reposCount),
|
||||||
onClick = {
|
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.interactor.GetCategories
|
||||||
import tachiyomi.domain.category.model.Category
|
import tachiyomi.domain.category.model.Category
|
||||||
import tachiyomi.domain.download.service.DownloadPreferences
|
import tachiyomi.domain.download.service.DownloadPreferences
|
||||||
|
import tachiyomi.domain.download.service.LibraryUpdateDownloadMode
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
import tachiyomi.i18n.kmk.KMR
|
import tachiyomi.i18n.kmk.KMR
|
||||||
import tachiyomi.presentation.core.i18n.pluralStringResource
|
import tachiyomi.presentation.core.i18n.pluralStringResource
|
||||||
|
|
@ -77,6 +78,7 @@ object SettingsDownloadScreen : SearchableSettings {
|
||||||
allCategories = allCategories,
|
allCategories = allCategories,
|
||||||
),
|
),
|
||||||
getDownloadAheadGroup(downloadPreferences = downloadPreferences),
|
getDownloadAheadGroup(downloadPreferences = downloadPreferences),
|
||||||
|
getLibraryUpdateDownloadGroup(downloadPreferences = downloadPreferences),
|
||||||
// KMK -->
|
// KMK -->
|
||||||
getDownloadCacheRenewInterval(downloadPreferences = downloadPreferences),
|
getDownloadCacheRenewInterval(downloadPreferences = downloadPreferences),
|
||||||
// KMK <--
|
// 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 -->
|
// KMK -->
|
||||||
@Composable
|
@Composable
|
||||||
private fun getDownloadCacheRenewInterval(
|
private fun getDownloadCacheRenewInterval(
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import eu.kanade.tachiyomi.util.system.hasDisplayCutout
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.collections.immutable.persistentMapOf
|
import kotlinx.collections.immutable.persistentMapOf
|
||||||
import kotlinx.collections.immutable.toImmutableMap
|
import kotlinx.collections.immutable.toImmutableMap
|
||||||
|
import tachiyomi.domain.reading.service.ReadingEtaPreferences
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
import tachiyomi.i18n.kmk.KMR
|
import tachiyomi.i18n.kmk.KMR
|
||||||
import tachiyomi.i18n.sy.SYMR
|
import tachiyomi.i18n.sy.SYMR
|
||||||
|
|
@ -101,6 +102,7 @@ object SettingsReaderScreen : SearchableSettings {
|
||||||
),
|
),
|
||||||
SY <-- */
|
SY <-- */
|
||||||
getDisplayGroup(readerPreferences = readerPref),
|
getDisplayGroup(readerPreferences = readerPref),
|
||||||
|
getReadingEtaTuningGroup(),
|
||||||
getEInkGroup(readerPreferences = readerPref),
|
getEInkGroup(readerPreferences = readerPref),
|
||||||
getReadingGroup(readerPreferences = readerPref),
|
getReadingGroup(readerPreferences = readerPref),
|
||||||
getPagedGroup(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
|
@Composable
|
||||||
private fun getEInkGroup(readerPreferences: ReaderPreferences): Preference.PreferenceGroup {
|
private fun getEInkGroup(readerPreferences: ReaderPreferences): Preference.PreferenceGroup {
|
||||||
val flashPageState by readerPreferences.flashOnPageChange().collectAsState()
|
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.core.model.rememberScreenModel
|
||||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
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.ExtensionStoreConfirmDialog
|
||||||
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionRepoConflictDialog
|
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionStoreCreateDialog
|
||||||
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionRepoCreateDialog
|
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionStoreDeleteDialog
|
||||||
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionRepoDeleteDialog
|
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionStoresScreen
|
||||||
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionReposScreen
|
|
||||||
import eu.kanade.presentation.util.Screen
|
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.openInBrowser
|
||||||
import eu.kanade.tachiyomi.util.system.toast
|
import eu.kanade.tachiyomi.util.system.toast
|
||||||
import kotlinx.collections.immutable.toImmutableSet
|
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
|
||||||
import tachiyomi.i18n.kmk.KMR
|
import tachiyomi.i18n.kmk.KMR
|
||||||
import tachiyomi.presentation.core.screens.LoadingScreen
|
import tachiyomi.presentation.core.screens.LoadingScreen
|
||||||
|
|
||||||
class ExtensionReposScreen(
|
class ExtensionStoresScreen(
|
||||||
private val url: String? = null,
|
private val url: String? = null,
|
||||||
) : Screen() {
|
) : Screen() {
|
||||||
|
|
||||||
|
|
@ -30,80 +28,72 @@ class ExtensionReposScreen(
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val navigator = LocalNavigator.currentOrThrow
|
val navigator = LocalNavigator.currentOrThrow
|
||||||
|
|
||||||
val screenModel = rememberScreenModel { ExtensionReposScreenModel() }
|
val screenModel = rememberScreenModel { ExtensionStoresScreenModel() }
|
||||||
val state by screenModel.state.collectAsState()
|
val state by screenModel.state.collectAsState()
|
||||||
|
|
||||||
LaunchedEffect(url) {
|
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()
|
LoadingScreen()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val successState = state as RepoScreenState.Success
|
val successState = state as ExtensionStoreScreenState.Success
|
||||||
|
|
||||||
ExtensionReposScreen(
|
ExtensionStoresScreen(
|
||||||
state = successState,
|
state = successState,
|
||||||
onClickCreate = { screenModel.showDialog(RepoDialog.Create) },
|
onClickCreate = { screenModel.showDialog(ExtensionStoreDialog.Create()) },
|
||||||
onOpenWebsite = { context.openInBrowser(it.website) },
|
onCopy = { context.copyToClipboard(it.indexUrl, it.indexUrl) },
|
||||||
onClickDelete = { screenModel.showDialog(RepoDialog.Delete(it)) },
|
onOpenWebsite = { it.contact.website.let(context::openInBrowser) },
|
||||||
|
onOpenDiscord = { it.contact.discord?.let(context::openInBrowser) },
|
||||||
// KMK -->
|
// KMK -->
|
||||||
onClickEnable = {
|
onClickEnable = {
|
||||||
screenModel.enableRepo(it)
|
screenModel.enableStore(it.indexUrl)
|
||||||
screenModel.refreshExtensionList()
|
screenModel.refreshExtensionList()
|
||||||
context.toast(KMR.strings.extensions_page_need_refresh)
|
context.toast(KMR.strings.extensions_page_need_refresh)
|
||||||
},
|
},
|
||||||
onClickDisable = {
|
onClickDisable = {
|
||||||
screenModel.disableRepo(it)
|
screenModel.disableStore(it.indexUrl)
|
||||||
screenModel.refreshExtensionList()
|
screenModel.refreshExtensionList()
|
||||||
context.toast(KMR.strings.extensions_page_need_refresh)
|
context.toast(KMR.strings.extensions_page_need_refresh)
|
||||||
},
|
},
|
||||||
// KMK <--
|
// KMK <--
|
||||||
|
onClickDelete = { screenModel.showDialog(ExtensionStoreDialog.Delete(it)) },
|
||||||
onClickRefresh = { screenModel.refreshRepos() },
|
onClickRefresh = { screenModel.refreshRepos() },
|
||||||
navigateUp = navigator::pop,
|
navigateUp = navigator::pop,
|
||||||
)
|
)
|
||||||
|
|
||||||
when (val dialog = successState.dialog) {
|
when (val dialog = successState.dialog) {
|
||||||
null -> {}
|
null -> {}
|
||||||
is RepoDialog.Create -> {
|
is ExtensionStoreDialog.Create -> {
|
||||||
ExtensionRepoCreateDialog(
|
ExtensionStoreCreateDialog(
|
||||||
onDismissRequest = screenModel::dismissDialog,
|
onDismissRequest = screenModel::dismissDialog,
|
||||||
onCreate = { screenModel.createRepo(it) },
|
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 -> {
|
is ExtensionStoreDialog.Delete -> {
|
||||||
ExtensionRepoDeleteDialog(
|
ExtensionStoreDeleteDialog(
|
||||||
onDismissRequest = screenModel::dismissDialog,
|
onDismissRequest = screenModel::dismissDialog,
|
||||||
onDelete = { screenModel.deleteRepo(dialog.repo) },
|
onDelete = { screenModel.deleteRepo(dialog.store.indexUrl) },
|
||||||
repo = dialog.repo,
|
storeName = dialog.store.name,
|
||||||
|
storeIndexUrl = dialog.store.indexUrl,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is RepoDialog.Conflict -> {
|
is ExtensionStoreDialog.Confirm -> {
|
||||||
ExtensionRepoConflictDialog(
|
ExtensionStoreConfirmDialog(
|
||||||
onDismissRequest = screenModel::dismissDialog,
|
|
||||||
onMigrate = { screenModel.replaceRepo(dialog.newRepo) },
|
|
||||||
oldRepo = dialog.oldRepo,
|
|
||||||
newRepo = dialog.newRepo,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
is RepoDialog.Confirm -> {
|
|
||||||
ExtensionRepoConfirmDialog(
|
|
||||||
onDismissRequest = screenModel::dismissDialog,
|
onDismissRequest = screenModel::dismissDialog,
|
||||||
onCreate = { screenModel.createRepo(dialog.url) },
|
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.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.material.icons.Icons
|
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.ContentCopy
|
||||||
import androidx.compose.material.icons.outlined.Delete
|
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.Visibility
|
||||||
import androidx.compose.material.icons.outlined.VisibilityOff
|
import androidx.compose.material.icons.outlined.VisibilityOff
|
||||||
import androidx.compose.material3.ElevatedCard
|
import androidx.compose.material3.ElevatedCard
|
||||||
|
|
@ -26,33 +26,33 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.ImageBitmap
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.imageResource
|
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
import eu.kanade.tachiyomi.util.system.copyToClipboard
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.collections.immutable.ImmutableSet
|
import mihon.domain.extension.model.ExtensionStore
|
||||||
import kotlinx.collections.immutable.persistentSetOf
|
import mihon.domain.extension.model.KOMIKKU_SIGNATURE
|
||||||
import mihon.domain.extensionrepo.interactor.CreateExtensionRepo.Companion.KOMIKKU_SIGNATURE
|
import mihon.domain.extension.model.REPO_SIGNATURE
|
||||||
import mihon.domain.extensionrepo.interactor.CreateExtensionRepo.Companion.REPO_SIGNATURE
|
|
||||||
import mihon.domain.extensionrepo.model.ExtensionRepo
|
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
import tachiyomi.presentation.core.components.material.padding
|
import tachiyomi.presentation.core.components.material.padding
|
||||||
import tachiyomi.presentation.core.i18n.stringResource
|
import tachiyomi.presentation.core.i18n.stringResource
|
||||||
|
import tachiyomi.presentation.core.icons.CustomIcons
|
||||||
|
import tachiyomi.presentation.core.icons.Discord
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ExtensionReposContent(
|
fun ExtensionStoresContent(
|
||||||
repos: ImmutableSet<ExtensionRepo>,
|
repos: List<ExtensionStore>,
|
||||||
lazyListState: LazyListState,
|
lazyListState: LazyListState,
|
||||||
paddingValues: PaddingValues,
|
paddingValues: PaddingValues,
|
||||||
onOpenWebsite: (ExtensionRepo) -> Unit,
|
onCopy: (ExtensionStore) -> Unit,
|
||||||
onClickDelete: (String) -> Unit,
|
onOpenWebsite: (ExtensionStore) -> Unit,
|
||||||
|
onOpenDiscord: (ExtensionStore) -> Unit,
|
||||||
|
onClickDelete: (ExtensionStore) -> Unit,
|
||||||
// KMK -->
|
// KMK -->
|
||||||
onClickEnable: (String) -> Unit,
|
onClickEnable: (ExtensionStore) -> Unit,
|
||||||
onClickDisable: (String) -> Unit,
|
onClickDisable: (ExtensionStore) -> Unit,
|
||||||
disabledRepos: Set<String>,
|
disabledRepos: Set<String>,
|
||||||
// KMK <--
|
// KMK <--
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
|
@ -65,15 +65,17 @@ fun ExtensionReposContent(
|
||||||
) {
|
) {
|
||||||
repos.forEach {
|
repos.forEach {
|
||||||
item {
|
item {
|
||||||
ExtensionRepoListItem(
|
ExtensionStoresListItem(
|
||||||
modifier = Modifier.animateItem(),
|
modifier = Modifier.animateItem(),
|
||||||
repo = it,
|
store = it,
|
||||||
onOpenWebsite = { onOpenWebsite(it) },
|
onOpenWebsite = { onOpenWebsite(it) },
|
||||||
onDelete = { onClickDelete(it.baseUrl) },
|
onOpenDiscord = { onOpenDiscord(it) },
|
||||||
|
onCopy = { onCopy(it) },
|
||||||
|
onDelete = { onClickDelete(it) },
|
||||||
// KMK -->
|
// KMK -->
|
||||||
onEnable = { onClickEnable(it.baseUrl) },
|
onEnable = { onClickEnable(it) },
|
||||||
onDisable = { onClickDisable(it.baseUrl) },
|
onDisable = { onClickDisable(it) },
|
||||||
isDisabled = it.baseUrl in disabledRepos,
|
isDisabled = it.indexUrl in disabledRepos,
|
||||||
// KMK <--
|
// KMK <--
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -82,9 +84,11 @@ fun ExtensionReposContent(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ExtensionRepoListItem(
|
private fun ExtensionStoresListItem(
|
||||||
repo: ExtensionRepo,
|
store: ExtensionStore,
|
||||||
onOpenWebsite: () -> Unit,
|
onOpenWebsite: () -> Unit,
|
||||||
|
onOpenDiscord: () -> Unit,
|
||||||
|
onCopy: () -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
// KMK -->
|
// KMK -->
|
||||||
|
|
@ -93,8 +97,6 @@ private fun ExtensionRepoListItem(
|
||||||
onDisable: () -> Unit,
|
onDisable: () -> Unit,
|
||||||
// KMK <--
|
// KMK <--
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
ElevatedCard(
|
ElevatedCard(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
|
|
@ -103,9 +105,9 @@ private fun ExtensionRepoListItem(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(start = MaterialTheme.padding.medium),
|
.padding(start = MaterialTheme.padding.medium),
|
||||||
) {
|
) {
|
||||||
val resId = repoResId(repo.signingKeyFingerprint)
|
val resId = repoResId(store.signingKey)
|
||||||
Image(
|
Image(
|
||||||
bitmap = ImageBitmap.imageResource(id = resId),
|
painter = painterResource(id = resId),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
alpha = if (isDisabled) 0.4f else 1f,
|
alpha = if (isDisabled) 0.4f else 1f,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -126,7 +128,7 @@ private fun ExtensionRepoListItem(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = repo.name,
|
text = store.name,
|
||||||
// KMK: modifier = Modifier.padding(start = MaterialTheme.padding.medium),
|
// KMK: modifier = Modifier.padding(start = MaterialTheme.padding.medium),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
// KMK -->
|
// KMK -->
|
||||||
|
|
@ -142,17 +144,21 @@ private fun ExtensionRepoListItem(
|
||||||
) {
|
) {
|
||||||
IconButton(onClick = onOpenWebsite) {
|
IconButton(onClick = onOpenWebsite) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.AutoMirrored.Outlined.OpenInNew,
|
imageVector = Icons.Outlined.Public,
|
||||||
contentDescription = stringResource(MR.strings.action_open_in_browser),
|
contentDescription = stringResource(MR.strings.action_open_in_browser),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
IconButton(
|
if (store.contact.discord != null) {
|
||||||
onClick = {
|
IconButton(onClick = onOpenDiscord) {
|
||||||
val url = "${repo.baseUrl}/index.min.json"
|
Icon(
|
||||||
context.copyToClipboard(url, url)
|
imageVector = CustomIcons.Discord,
|
||||||
},
|
contentDescription = null,
|
||||||
) {
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(onClick = onCopy) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Outlined.ContentCopy,
|
imageVector = Icons.Outlined.ContentCopy,
|
||||||
contentDescription = stringResource(MR.strings.action_copy_to_clipboard),
|
contentDescription = stringResource(MR.strings.action_copy_to_clipboard),
|
||||||
|
|
@ -190,16 +196,18 @@ fun repoResId(signKey: String) = when (signKey) {
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun ExtensionReposContentPreview() {
|
fun ExtensionReposContentPreview() {
|
||||||
val repos = persistentSetOf(
|
val repos = persistentListOf(
|
||||||
ExtensionRepo("https://repo", "Komikku", "", "", KOMIKKU_SIGNATURE),
|
ExtensionStore("https://repo", "Komikku", "", KOMIKKU_SIGNATURE, ExtensionStore.Contact("", ""), false),
|
||||||
ExtensionRepo("https://repo", "Repo", "", "", REPO_SIGNATURE),
|
ExtensionStore("https://repo", "Repo", "", REPO_SIGNATURE, ExtensionStore.Contact("", ""), false),
|
||||||
ExtensionRepo("https://repo", "Other", "", "", "key2"),
|
ExtensionStore("https://repo", "Other", "", "key2", ExtensionStore.Contact("", ""), true),
|
||||||
)
|
)
|
||||||
ExtensionReposContent(
|
ExtensionStoresContent(
|
||||||
repos = repos,
|
repos = repos,
|
||||||
lazyListState = LazyListState(),
|
lazyListState = LazyListState(),
|
||||||
paddingValues = PaddingValues(),
|
paddingValues = PaddingValues(),
|
||||||
|
onCopy = {},
|
||||||
onOpenWebsite = {},
|
onOpenWebsite = {},
|
||||||
|
onOpenDiscord = {},
|
||||||
onClickDelete = {},
|
onClickDelete = {},
|
||||||
onClickEnable = {},
|
onClickEnable = {},
|
||||||
onClickDisable = {},
|
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
|
package eu.kanade.presentation.more.settings.screen.browse.components
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
|
@ -21,13 +19,13 @@ import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import eu.kanade.presentation.category.components.CategoryFloatingActionButton
|
import eu.kanade.presentation.category.components.CategoryFloatingActionButton
|
||||||
import eu.kanade.presentation.components.AppBar
|
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 eu.kanade.tachiyomi.util.system.openInBrowser
|
||||||
import kotlinx.collections.immutable.persistentSetOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import mihon.domain.extensionrepo.interactor.CreateExtensionRepo.Companion.KOMIKKU_SIGNATURE
|
import mihon.domain.extension.model.ExtensionStore
|
||||||
import mihon.domain.extensionrepo.interactor.CreateExtensionRepo.Companion.REPO_HELP
|
import mihon.domain.extension.model.KOMIKKU_SIGNATURE
|
||||||
import mihon.domain.extensionrepo.interactor.CreateExtensionRepo.Companion.REPO_SIGNATURE
|
import mihon.domain.extension.model.REPO_HELP
|
||||||
import mihon.domain.extensionrepo.model.ExtensionRepo
|
import mihon.domain.extension.model.REPO_SIGNATURE
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
import tachiyomi.presentation.core.components.material.Scaffold
|
import tachiyomi.presentation.core.components.material.Scaffold
|
||||||
import tachiyomi.presentation.core.components.material.padding
|
import tachiyomi.presentation.core.components.material.padding
|
||||||
|
|
@ -37,14 +35,16 @@ import tachiyomi.presentation.core.screens.EmptyScreen
|
||||||
import tachiyomi.presentation.core.util.plus
|
import tachiyomi.presentation.core.util.plus
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ExtensionReposScreen(
|
fun ExtensionStoresScreen(
|
||||||
state: RepoScreenState.Success,
|
state: ExtensionStoreScreenState.Success,
|
||||||
onClickCreate: () -> Unit,
|
onClickCreate: () -> Unit,
|
||||||
onOpenWebsite: (ExtensionRepo) -> Unit,
|
onCopy: (ExtensionStore) -> Unit,
|
||||||
onClickDelete: (String) -> Unit,
|
onOpenWebsite: (ExtensionStore) -> Unit,
|
||||||
|
onOpenDiscord: (ExtensionStore) -> Unit,
|
||||||
|
onClickDelete: (ExtensionStore) -> Unit,
|
||||||
// KMK -->
|
// KMK -->
|
||||||
onClickEnable: (String) -> Unit,
|
onClickEnable: (ExtensionStore) -> Unit,
|
||||||
onClickDisable: (String) -> Unit,
|
onClickDisable: (ExtensionStore) -> Unit,
|
||||||
// KMK <--
|
// KMK <--
|
||||||
onClickRefresh: () -> Unit,
|
onClickRefresh: () -> Unit,
|
||||||
navigateUp: () -> Unit,
|
navigateUp: () -> Unit,
|
||||||
|
|
@ -54,7 +54,7 @@ fun ExtensionReposScreen(
|
||||||
topBar = { scrollBehavior ->
|
topBar = { scrollBehavior ->
|
||||||
AppBar(
|
AppBar(
|
||||||
navigateUp = navigateUp,
|
navigateUp = navigateUp,
|
||||||
title = stringResource(MR.strings.label_extension_repos),
|
title = stringResource(MR.strings.extensionStores),
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
actions = {
|
actions = {
|
||||||
IconButton(onClick = onClickRefresh) {
|
IconButton(onClick = onClickRefresh) {
|
||||||
|
|
@ -76,7 +76,7 @@ fun ExtensionReposScreen(
|
||||||
if (state.isEmpty) {
|
if (state.isEmpty) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
EmptyScreen(
|
EmptyScreen(
|
||||||
MR.strings.information_empty_repos,
|
MR.strings.extensionStoresScreen_emptyLabel,
|
||||||
modifier = Modifier.padding(paddingValues),
|
modifier = Modifier.padding(paddingValues),
|
||||||
// KMK -->
|
// KMK -->
|
||||||
help = {
|
help = {
|
||||||
|
|
@ -94,12 +94,14 @@ fun ExtensionReposScreen(
|
||||||
return@Scaffold
|
return@Scaffold
|
||||||
}
|
}
|
||||||
|
|
||||||
ExtensionReposContent(
|
ExtensionStoresContent(
|
||||||
repos = state.repos,
|
repos = state.stores,
|
||||||
lazyListState = lazyListState,
|
lazyListState = lazyListState,
|
||||||
paddingValues = paddingValues + topSmallPaddingValues +
|
paddingValues = paddingValues + topSmallPaddingValues +
|
||||||
PaddingValues(horizontal = MaterialTheme.padding.medium),
|
PaddingValues(horizontal = MaterialTheme.padding.medium),
|
||||||
|
onCopy = onCopy,
|
||||||
onOpenWebsite = onOpenWebsite,
|
onOpenWebsite = onOpenWebsite,
|
||||||
|
onOpenDiscord = onOpenDiscord,
|
||||||
onClickDelete = onClickDelete,
|
onClickDelete = onClickDelete,
|
||||||
// KMK -->
|
// KMK -->
|
||||||
onClickEnable = onClickEnable,
|
onClickEnable = onClickEnable,
|
||||||
|
|
@ -113,19 +115,21 @@ fun ExtensionReposScreen(
|
||||||
// KMK -->
|
// KMK -->
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
private fun ExtensionReposScreenPreview() {
|
private fun ExtensionStoresScreenPreview() {
|
||||||
val state = RepoScreenState.Success(
|
val state = ExtensionStoreScreenState.Success(
|
||||||
repos = persistentSetOf(
|
stores = persistentListOf(
|
||||||
ExtensionRepo("https://repo", "Komikku", "", "", KOMIKKU_SIGNATURE),
|
ExtensionStore("https://repo", "Komikku", "", KOMIKKU_SIGNATURE, ExtensionStore.Contact("", ""), false),
|
||||||
ExtensionRepo("https://repo", "Repo", "", "", REPO_SIGNATURE),
|
ExtensionStore("https://repo", "Repo", "", REPO_SIGNATURE, ExtensionStore.Contact("", ""), false),
|
||||||
ExtensionRepo("https://repo", "Other", "", "", "key2"),
|
ExtensionStore("https://repo", "Other", "", "key2", ExtensionStore.Contact("", ""), true),
|
||||||
),
|
),
|
||||||
disabledRepos = setOf("https://repo"),
|
disabledRepos = setOf("https://repo"),
|
||||||
)
|
)
|
||||||
ExtensionReposScreen(
|
ExtensionStoresScreen(
|
||||||
state = state,
|
state = state,
|
||||||
onClickCreate = { },
|
onClickCreate = { },
|
||||||
|
onCopy = { },
|
||||||
onOpenWebsite = { },
|
onOpenWebsite = { },
|
||||||
|
onOpenDiscord = { },
|
||||||
onClickDelete = { },
|
onClickDelete = { },
|
||||||
onClickEnable = { },
|
onClickEnable = { },
|
||||||
onClickDisable = { },
|
onClickDisable = { },
|
||||||
|
|
@ -136,12 +140,14 @@ private fun ExtensionReposScreenPreview() {
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
private fun ExtensionReposScreenEmptyPreview() {
|
private fun ExtensionStoresScreenEmptyPreview() {
|
||||||
val state = RepoScreenState.Success(repos = persistentSetOf())
|
val state = ExtensionStoreScreenState.Success(stores = persistentListOf())
|
||||||
ExtensionReposScreen(
|
ExtensionStoresScreen(
|
||||||
state = state,
|
state = state,
|
||||||
onClickCreate = { },
|
onClickCreate = { },
|
||||||
|
onCopy = { },
|
||||||
onOpenWebsite = { },
|
onOpenWebsite = { },
|
||||||
|
onOpenDiscord = { },
|
||||||
onClickDelete = { },
|
onClickDelete = { },
|
||||||
onClickEnable = { },
|
onClickEnable = { },
|
||||||
onClickDisable = { },
|
onClickDisable = { },
|
||||||
|
|
@ -114,7 +114,7 @@ private class SyncSettingsSelectorModel(
|
||||||
tracking = syncSettings.tracking,
|
tracking = syncSettings.tracking,
|
||||||
history = syncSettings.history,
|
history = syncSettings.history,
|
||||||
appSettings = syncSettings.appSettings,
|
appSettings = syncSettings.appSettings,
|
||||||
extensionRepoSettings = syncSettings.extensionRepoSettings,
|
extensionStores = syncSettings.extensionStores,
|
||||||
sourceSettings = syncSettings.sourceSettings,
|
sourceSettings = syncSettings.sourceSettings,
|
||||||
privateSettings = syncSettings.privateSettings,
|
privateSettings = syncSettings.privateSettings,
|
||||||
|
|
||||||
|
|
@ -134,7 +134,7 @@ private class SyncSettingsSelectorModel(
|
||||||
tracking = backupOptions.tracking,
|
tracking = backupOptions.tracking,
|
||||||
history = backupOptions.history,
|
history = backupOptions.history,
|
||||||
appSettings = backupOptions.appSettings,
|
appSettings = backupOptions.appSettings,
|
||||||
extensionRepoSettings = backupOptions.extensionRepoSettings,
|
extensionStores = backupOptions.extensionStores,
|
||||||
sourceSettings = backupOptions.sourceSettings,
|
sourceSettings = backupOptions.sourceSettings,
|
||||||
privateSettings = backupOptions.privateSettings,
|
privateSettings = backupOptions.privateSettings,
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -171,7 +171,6 @@ internal fun LazyListScope.updatesUiItems(
|
||||||
// KMK -->
|
// KMK -->
|
||||||
UpdateSelectionOptions(
|
UpdateSelectionOptions(
|
||||||
selected = !updatesItem.selected,
|
selected = !updatesItem.selected,
|
||||||
userSelected = true,
|
|
||||||
fromLongPress = true,
|
fromLongPress = true,
|
||||||
isGroup = isLeader && item.isExpandable,
|
isGroup = isLeader && item.isExpandable,
|
||||||
isExpanded = isExpanded,
|
isExpanded = isExpanded,
|
||||||
|
|
@ -186,7 +185,6 @@ internal fun LazyListScope.updatesUiItems(
|
||||||
// KMK -->
|
// KMK -->
|
||||||
UpdateSelectionOptions(
|
UpdateSelectionOptions(
|
||||||
selected = !updatesItem.selected,
|
selected = !updatesItem.selected,
|
||||||
userSelected = true,
|
|
||||||
fromLongPress = false,
|
fromLongPress = false,
|
||||||
isGroup = isLeader && item.isExpandable,
|
isGroup = isLeader && item.isExpandable,
|
||||||
isExpanded = isExpanded,
|
isExpanded = isExpanded,
|
||||||
|
|
@ -288,8 +286,8 @@ private fun UpdatesUiItem(
|
||||||
.combinedClickable(
|
.combinedClickable(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
onLongClick = {
|
onLongClick = {
|
||||||
onLongClick()
|
|
||||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
|
onLongClick()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.padding(top = if (isLeader) MaterialTheme.padding.small else 0.dp)
|
.padding(top = if (isLeader) MaterialTheme.padding.small else 0.dp)
|
||||||
|
|
@ -311,7 +309,7 @@ private fun UpdatesUiItem(
|
||||||
MangaCoverHide.Book(
|
MangaCoverHide.Book(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(UpdateItemWidth),
|
.width(UpdateItemWidth),
|
||||||
bgColor = bgColor ?: (MaterialTheme.colorScheme.surface.takeIf { selected }),
|
bgColor = bgColor ?: MaterialTheme.colorScheme.surface.takeIf { selected },
|
||||||
tint = onBgColor,
|
tint = onBgColor,
|
||||||
size = MangaCover.Size.Medium,
|
size = MangaCover.Size.Medium,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,7 @@ fun EhLoginWebViewScreen(
|
||||||
)
|
)
|
||||||
is LoadingState.Loading -> {
|
is LoadingState.Loading -> {
|
||||||
val animatedProgress by animateFloatAsState(
|
val animatedProgress by animateFloatAsState(
|
||||||
(loadingState as? LoadingState.Loading)?.progress ?: 1f,
|
loadingState.progress,
|
||||||
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
|
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
|
||||||
label = "webview_loading",
|
label = "webview_loading",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import com.hippo.unifile.UniFile
|
||||||
import eu.kanade.tachiyomi.BuildConfig
|
import eu.kanade.tachiyomi.BuildConfig
|
||||||
import eu.kanade.tachiyomi.data.backup.BackupFileValidator
|
import eu.kanade.tachiyomi.data.backup.BackupFileValidator
|
||||||
import eu.kanade.tachiyomi.data.backup.create.creators.CategoriesBackupCreator
|
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.FeedBackupCreator
|
||||||
import eu.kanade.tachiyomi.data.backup.create.creators.MangaBackupCreator
|
import eu.kanade.tachiyomi.data.backup.create.creators.MangaBackupCreator
|
||||||
import eu.kanade.tachiyomi.data.backup.create.creators.PreferenceBackupCreator
|
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.create.creators.SourcesBackupCreator
|
||||||
import eu.kanade.tachiyomi.data.backup.models.Backup
|
import eu.kanade.tachiyomi.data.backup.models.Backup
|
||||||
import eu.kanade.tachiyomi.data.backup.models.BackupCategory
|
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.BackupFeed
|
||||||
import eu.kanade.tachiyomi.data.backup.models.BackupManga
|
import eu.kanade.tachiyomi.data.backup.models.BackupManga
|
||||||
import eu.kanade.tachiyomi.data.backup.models.BackupPreference
|
import eu.kanade.tachiyomi.data.backup.models.BackupPreference
|
||||||
|
|
@ -54,7 +54,7 @@ class BackupCreator(
|
||||||
private val categoriesBackupCreator: CategoriesBackupCreator = CategoriesBackupCreator(),
|
private val categoriesBackupCreator: CategoriesBackupCreator = CategoriesBackupCreator(),
|
||||||
private val mangaBackupCreator: MangaBackupCreator = MangaBackupCreator(),
|
private val mangaBackupCreator: MangaBackupCreator = MangaBackupCreator(),
|
||||||
private val preferenceBackupCreator: PreferenceBackupCreator = PreferenceBackupCreator(),
|
private val preferenceBackupCreator: PreferenceBackupCreator = PreferenceBackupCreator(),
|
||||||
private val extensionRepoBackupCreator: ExtensionRepoBackupCreator = ExtensionRepoBackupCreator(),
|
private val extensionStoresBackupCreator: ExtensionStoresBackupCreator = ExtensionStoresBackupCreator(),
|
||||||
private val sourcesBackupCreator: SourcesBackupCreator = SourcesBackupCreator(),
|
private val sourcesBackupCreator: SourcesBackupCreator = SourcesBackupCreator(),
|
||||||
// KMK -->
|
// KMK -->
|
||||||
private val feedBackupCreator: FeedBackupCreator = FeedBackupCreator(),
|
private val feedBackupCreator: FeedBackupCreator = FeedBackupCreator(),
|
||||||
|
|
@ -101,7 +101,7 @@ class BackupCreator(
|
||||||
backupCategories = backupCategories(options),
|
backupCategories = backupCategories(options),
|
||||||
backupSources = backupSources(backupManga),
|
backupSources = backupSources(backupManga),
|
||||||
backupPreferences = backupAppPreferences(options),
|
backupPreferences = backupAppPreferences(options),
|
||||||
backupExtensionRepo = backupExtensionRepos(options),
|
backupExtensionStores = backupExtensionStores(options),
|
||||||
backupSourcePreferences = backupSourcePreferences(options),
|
backupSourcePreferences = backupSourcePreferences(options),
|
||||||
|
|
||||||
// SY -->
|
// SY -->
|
||||||
|
|
@ -159,16 +159,16 @@ class BackupCreator(
|
||||||
return sourcesBackupCreator(mangas)
|
return sourcesBackupCreator(mangas)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* KMK --> */ suspend /* KMK <-- */ fun backupAppPreferences(options: BackupOptions): List<BackupPreference> {
|
internal fun backupAppPreferences(options: BackupOptions): List<BackupPreference> {
|
||||||
if (!options.appSettings) return emptyList()
|
if (!options.appSettings) return emptyList()
|
||||||
|
|
||||||
return preferenceBackupCreator.createApp(includePrivatePreferences = options.privateSettings)
|
return preferenceBackupCreator.createApp(includePrivatePreferences = options.privateSettings)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun backupExtensionRepos(options: BackupOptions): List<BackupExtensionRepos> {
|
internal suspend fun backupExtensionStores(options: BackupOptions): List<BackupExtensionStore> {
|
||||||
if (!options.extensionRepoSettings) return emptyList()
|
if (!options.extensionStores) return emptyList()
|
||||||
|
|
||||||
return extensionRepoBackupCreator()
|
return extensionStoresBackupCreator()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun backupSourcePreferences(options: BackupOptions): List<BackupSourcePreferences> {
|
fun backupSourcePreferences(options: BackupOptions): List<BackupSourcePreferences> {
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ data class BackupOptions(
|
||||||
val history: Boolean = true,
|
val history: Boolean = true,
|
||||||
val readEntries: Boolean = true,
|
val readEntries: Boolean = true,
|
||||||
val appSettings: Boolean = true,
|
val appSettings: Boolean = true,
|
||||||
val extensionRepoSettings: Boolean = true,
|
val extensionStores: Boolean = true,
|
||||||
val sourceSettings: Boolean = true,
|
val sourceSettings: Boolean = true,
|
||||||
val privateSettings: Boolean = false,
|
val privateSettings: Boolean = false,
|
||||||
// SY -->
|
// SY -->
|
||||||
|
|
@ -31,7 +31,7 @@ data class BackupOptions(
|
||||||
history,
|
history,
|
||||||
readEntries,
|
readEntries,
|
||||||
appSettings,
|
appSettings,
|
||||||
extensionRepoSettings,
|
extensionStores,
|
||||||
sourceSettings,
|
sourceSettings,
|
||||||
privateSettings,
|
privateSettings,
|
||||||
// SY -->
|
// SY -->
|
||||||
|
|
@ -41,7 +41,7 @@ data class BackupOptions(
|
||||||
)
|
)
|
||||||
|
|
||||||
fun canCreate() =
|
fun canCreate() =
|
||||||
libraryEntries || categories || appSettings || extensionRepoSettings || sourceSettings || savedSearchesFeeds
|
libraryEntries || categories || appSettings || extensionStores || sourceSettings || savedSearchesFeeds
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val libraryOptions = persistentListOf(
|
val libraryOptions = persistentListOf(
|
||||||
|
|
@ -103,9 +103,9 @@ data class BackupOptions(
|
||||||
setter = { options, enabled -> options.copy(appSettings = enabled) },
|
setter = { options, enabled -> options.copy(appSettings = enabled) },
|
||||||
),
|
),
|
||||||
Entry(
|
Entry(
|
||||||
label = MR.strings.extensionRepo_settings,
|
label = MR.strings.extensionStores,
|
||||||
getter = BackupOptions::extensionRepoSettings,
|
getter = BackupOptions::extensionStores,
|
||||||
setter = { options, enabled -> options.copy(extensionRepoSettings = enabled) },
|
setter = { options, enabled -> options.copy(extensionStores = enabled) },
|
||||||
),
|
),
|
||||||
Entry(
|
Entry(
|
||||||
label = MR.strings.source_settings,
|
label = MR.strings.source_settings,
|
||||||
|
|
@ -128,7 +128,7 @@ data class BackupOptions(
|
||||||
history = array[4],
|
history = array[4],
|
||||||
readEntries = array[5],
|
readEntries = array[5],
|
||||||
appSettings = array[6],
|
appSettings = array[6],
|
||||||
extensionRepoSettings = array[7],
|
extensionStores = array[7],
|
||||||
sourceSettings = array[8],
|
sourceSettings = array[8],
|
||||||
privateSettings = array[9],
|
privateSettings = array[9],
|
||||||
// SY -->
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,7 +11,7 @@ data class Backup(
|
||||||
@ProtoNumber(101) var backupSources: List<BackupSource> = emptyList(),
|
@ProtoNumber(101) var backupSources: List<BackupSource> = emptyList(),
|
||||||
@ProtoNumber(104) var backupPreferences: List<BackupPreference> = emptyList(),
|
@ProtoNumber(104) var backupPreferences: List<BackupPreference> = emptyList(),
|
||||||
@ProtoNumber(105) var backupSourcePreferences: List<BackupSourcePreferences> = 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
|
// SY specific values
|
||||||
@ProtoNumber(600) var backupSavedSearches: List<BackupSavedSearch> = emptyList(),
|
@ProtoNumber(600) var backupSavedSearches: List<BackupSavedSearch> = emptyList(),
|
||||||
// KMK -->
|
// 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -5,14 +5,14 @@ import android.net.Uri
|
||||||
import eu.kanade.tachiyomi.data.backup.BackupDecoder
|
import eu.kanade.tachiyomi.data.backup.BackupDecoder
|
||||||
import eu.kanade.tachiyomi.data.backup.BackupNotifier
|
import eu.kanade.tachiyomi.data.backup.BackupNotifier
|
||||||
import eu.kanade.tachiyomi.data.backup.models.BackupCategory
|
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.BackupFeed
|
||||||
import eu.kanade.tachiyomi.data.backup.models.BackupManga
|
import eu.kanade.tachiyomi.data.backup.models.BackupManga
|
||||||
import eu.kanade.tachiyomi.data.backup.models.BackupPreference
|
import eu.kanade.tachiyomi.data.backup.models.BackupPreference
|
||||||
import eu.kanade.tachiyomi.data.backup.models.BackupSavedSearch
|
import eu.kanade.tachiyomi.data.backup.models.BackupSavedSearch
|
||||||
import eu.kanade.tachiyomi.data.backup.models.BackupSourcePreferences
|
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.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.FeedRestorer
|
||||||
import eu.kanade.tachiyomi.data.backup.restore.restorers.MangaRestorer
|
import eu.kanade.tachiyomi.data.backup.restore.restorers.MangaRestorer
|
||||||
import eu.kanade.tachiyomi.data.backup.restore.restorers.PreferenceRestorer
|
import eu.kanade.tachiyomi.data.backup.restore.restorers.PreferenceRestorer
|
||||||
|
|
@ -38,7 +38,7 @@ class BackupRestorer(
|
||||||
|
|
||||||
private val categoriesRestorer: CategoriesRestorer = CategoriesRestorer(),
|
private val categoriesRestorer: CategoriesRestorer = CategoriesRestorer(),
|
||||||
private val preferenceRestorer: PreferenceRestorer = PreferenceRestorer(context),
|
private val preferenceRestorer: PreferenceRestorer = PreferenceRestorer(context),
|
||||||
private val extensionRepoRestorer: ExtensionRepoRestorer = ExtensionRepoRestorer(),
|
private val extensionStoreRestorer: ExtensionStoreRestorer = ExtensionStoreRestorer(),
|
||||||
private val mangaRestorer: MangaRestorer = MangaRestorer(isSync),
|
private val mangaRestorer: MangaRestorer = MangaRestorer(isSync),
|
||||||
// SY -->
|
// SY -->
|
||||||
private val savedSearchRestorer: SavedSearchRestorer = SavedSearchRestorer(),
|
private val savedSearchRestorer: SavedSearchRestorer = SavedSearchRestorer(),
|
||||||
|
|
@ -96,8 +96,8 @@ class BackupRestorer(
|
||||||
if (options.appSettings) {
|
if (options.appSettings) {
|
||||||
restoreAmount += 1
|
restoreAmount += 1
|
||||||
}
|
}
|
||||||
if (options.extensionRepoSettings) {
|
if (options.extensionStores) {
|
||||||
restoreAmount += backup.backupExtensionRepo.size
|
restoreAmount += backup.backupExtensionStores.size
|
||||||
}
|
}
|
||||||
if (options.sourceSettings) {
|
if (options.sourceSettings) {
|
||||||
restoreAmount += 1
|
restoreAmount += 1
|
||||||
|
|
@ -126,8 +126,8 @@ class BackupRestorer(
|
||||||
if (options.libraryEntries) {
|
if (options.libraryEntries) {
|
||||||
restoreManga(backup.backupManga, if (options.categories) backup.backupCategories else emptyList())
|
restoreManga(backup.backupManga, if (options.categories) backup.backupCategories else emptyList())
|
||||||
}
|
}
|
||||||
if (options.extensionRepoSettings) {
|
if (options.extensionStores) {
|
||||||
restoreExtensionRepos(backup.backupExtensionRepo)
|
restoreExtensionStores(backup.backupExtensionStores)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: optionally trigger online library + tracker update
|
// TODO: optionally trigger online library + tracker update
|
||||||
|
|
@ -248,23 +248,25 @@ class BackupRestorer(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun CoroutineScope.restoreExtensionRepos(
|
private fun CoroutineScope.restoreExtensionStores(
|
||||||
backupExtensionRepo: List<BackupExtensionRepos>,
|
backupExtensionStores: List<BackupExtensionStore>,
|
||||||
) = launch {
|
) = launch {
|
||||||
backupExtensionRepo
|
backupExtensionStores
|
||||||
.forEach {
|
.forEach {
|
||||||
ensureActive()
|
ensureActive()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
extensionRepoRestorer(it)
|
extensionStoreRestorer(it)
|
||||||
} catch (e: Exception) {
|
} 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
|
restoreProgress += 1
|
||||||
|
// KMK -->
|
||||||
with(notifier) {
|
with(notifier) {
|
||||||
|
// KMK <--
|
||||||
showRestoreProgress(
|
showRestoreProgress(
|
||||||
context.stringResource(MR.strings.extensionRepo_settings),
|
context.stringResource(MR.strings.extensionStores),
|
||||||
restoreProgress,
|
restoreProgress,
|
||||||
restoreAmount,
|
restoreAmount,
|
||||||
isSync,
|
isSync,
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ data class RestoreOptions(
|
||||||
val libraryEntries: Boolean = true,
|
val libraryEntries: Boolean = true,
|
||||||
val categories: Boolean = true,
|
val categories: Boolean = true,
|
||||||
val appSettings: Boolean = true,
|
val appSettings: Boolean = true,
|
||||||
val extensionRepoSettings: Boolean = true,
|
val extensionStores: Boolean = true,
|
||||||
val sourceSettings: Boolean = true,
|
val sourceSettings: Boolean = true,
|
||||||
// SY -->
|
// SY -->
|
||||||
val savedSearchesFeeds: Boolean = true,
|
val savedSearchesFeeds: Boolean = true,
|
||||||
|
|
@ -20,7 +20,7 @@ data class RestoreOptions(
|
||||||
libraryEntries,
|
libraryEntries,
|
||||||
categories,
|
categories,
|
||||||
appSettings,
|
appSettings,
|
||||||
extensionRepoSettings,
|
extensionStores,
|
||||||
sourceSettings,
|
sourceSettings,
|
||||||
// SY -->
|
// SY -->
|
||||||
savedSearchesFeeds,
|
savedSearchesFeeds,
|
||||||
|
|
@ -31,7 +31,7 @@ data class RestoreOptions(
|
||||||
libraryEntries ||
|
libraryEntries ||
|
||||||
categories ||
|
categories ||
|
||||||
appSettings ||
|
appSettings ||
|
||||||
extensionRepoSettings ||
|
extensionStores ||
|
||||||
sourceSettings /* SY --> */ ||
|
sourceSettings /* SY --> */ ||
|
||||||
savedSearchesFeeds /* SY <-- */
|
savedSearchesFeeds /* SY <-- */
|
||||||
|
|
||||||
|
|
@ -53,9 +53,9 @@ data class RestoreOptions(
|
||||||
setter = { options, enabled -> options.copy(appSettings = enabled) },
|
setter = { options, enabled -> options.copy(appSettings = enabled) },
|
||||||
),
|
),
|
||||||
Entry(
|
Entry(
|
||||||
label = MR.strings.extensionRepo_settings,
|
label = MR.strings.extensionStores,
|
||||||
getter = RestoreOptions::extensionRepoSettings,
|
getter = RestoreOptions::extensionStores,
|
||||||
setter = { options, enabled -> options.copy(extensionRepoSettings = enabled) },
|
setter = { options, enabled -> options.copy(extensionStores = enabled) },
|
||||||
),
|
),
|
||||||
Entry(
|
Entry(
|
||||||
label = MR.strings.source_settings,
|
label = MR.strings.source_settings,
|
||||||
|
|
@ -77,7 +77,7 @@ data class RestoreOptions(
|
||||||
libraryEntries = array[0],
|
libraryEntries = array[0],
|
||||||
categories = array[1],
|
categories = array[1],
|
||||||
appSettings = array[2],
|
appSettings = array[2],
|
||||||
extensionRepoSettings = array[3],
|
extensionStores = array[3],
|
||||||
sourceSettings = array[4],
|
sourceSettings = array[4],
|
||||||
// SY -->
|
// SY -->
|
||||||
savedSearchesFeeds = array[5],
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -58,6 +58,7 @@ import kotlinx.coroutines.sync.Semaphore
|
||||||
import kotlinx.coroutines.sync.withPermit
|
import kotlinx.coroutines.sync.withPermit
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import mihon.domain.chapter.interactor.FilterChaptersForDownload
|
import mihon.domain.chapter.interactor.FilterChaptersForDownload
|
||||||
|
import mihon.domain.chapter.interactor.GetChaptersForLibraryUpdateDownload
|
||||||
import tachiyomi.core.common.i18n.stringResource
|
import tachiyomi.core.common.i18n.stringResource
|
||||||
import tachiyomi.core.common.preference.getAndSet
|
import tachiyomi.core.common.preference.getAndSet
|
||||||
import tachiyomi.core.common.util.lang.withIOContext
|
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 syncChaptersWithSource: SyncChaptersWithSource = Injekt.get()
|
||||||
private val fetchInterval: FetchInterval = Injekt.get()
|
private val fetchInterval: FetchInterval = Injekt.get()
|
||||||
private val filterChaptersForDownload: FilterChaptersForDownload = Injekt.get()
|
private val filterChaptersForDownload: FilterChaptersForDownload = Injekt.get()
|
||||||
|
private val getChaptersForLibraryUpdateDownload: GetChaptersForLibraryUpdateDownload = Injekt.get()
|
||||||
|
|
||||||
// SY -->
|
// SY -->
|
||||||
private val getFavorites: GetFavorites = Injekt.get()
|
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
|
// Convert to the manga that contains new chapters
|
||||||
newUpdates.add(manga to newChapters.toTypedArray())
|
newUpdates.add(manga to newChapters.toTypedArray())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val libraryUpdateChapters = getChaptersForLibraryUpdateDownload.await(manga)
|
||||||
|
if (libraryUpdateChapters.isNotEmpty()) {
|
||||||
|
downloadChapters(manga, libraryUpdateChapters)
|
||||||
|
hasDownloads.store(true)
|
||||||
|
}
|
||||||
clearErrorFromDB(mangaId = manga.id)
|
clearErrorFromDB(mangaId = manga.id)
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
val errorMessage = when (e) {
|
val errorMessage = when (e) {
|
||||||
|
|
@ -493,9 +501,10 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
|
||||||
|
|
||||||
if (newUpdates.isNotEmpty()) {
|
if (newUpdates.isNotEmpty()) {
|
||||||
notifier.showUpdateNotifications(newUpdates)
|
notifier.showUpdateNotifications(newUpdates)
|
||||||
if (hasDownloads.load()) {
|
}
|
||||||
downloadManager.startDownloads()
|
|
||||||
}
|
if (hasDownloads.load()) {
|
||||||
|
downloadManager.startDownloads()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (failedUpdates.isNotEmpty()) {
|
if (failedUpdates.isNotEmpty()) {
|
||||||
|
|
@ -748,10 +757,6 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
|
||||||
private const val KEY_MANGA_IDS = "manga_ids"
|
private const val KEY_MANGA_IDS = "manga_ids"
|
||||||
// KMK <--
|
// KMK <--
|
||||||
|
|
||||||
fun cancelAllWorks(context: Context) {
|
|
||||||
context.workManager.cancelAllWorkByTag(TAG)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setupTask(
|
fun setupTask(
|
||||||
context: Context,
|
context: Context,
|
||||||
prefInterval: Int? = null,
|
prefInterval: Int? = null,
|
||||||
|
|
@ -765,16 +770,19 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
|
||||||
} else {
|
} else {
|
||||||
NetworkType.CONNECTED
|
NetworkType.CONNECTED
|
||||||
}
|
}
|
||||||
val networkRequestBuilder = NetworkRequest.Builder()
|
val networkRequest = NetworkRequest.Builder().apply {
|
||||||
if (DEVICE_ONLY_ON_WIFI in restrictions) {
|
removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
|
||||||
networkRequestBuilder.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
|
if (DEVICE_ONLY_ON_WIFI in restrictions) {
|
||||||
}
|
addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
|
||||||
if (DEVICE_NETWORK_NOT_METERED in restrictions) {
|
}
|
||||||
networkRequestBuilder.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
|
if (DEVICE_NETWORK_NOT_METERED in restrictions) {
|
||||||
|
addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.build()
|
||||||
val constraints = Constraints.Builder()
|
val constraints = Constraints.Builder()
|
||||||
// 'networkRequest' only applies to Android 9+, otherwise 'networkType' is used
|
// 'networkRequest' only applies to Android 9+, otherwise 'networkType' is used
|
||||||
.setRequiredNetworkRequest(networkRequestBuilder.build(), networkType)
|
.setRequiredNetworkRequest(networkRequest, networkType)
|
||||||
.setRequiresCharging(DEVICE_CHARGING in restrictions)
|
.setRequiresCharging(DEVICE_CHARGING in restrictions)
|
||||||
.setRequiresBatteryNotLow(true)
|
.setRequiresBatteryNotLow(true)
|
||||||
.build()
|
.build()
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,7 @@ class SyncManager(
|
||||||
chapters = syncOptions.chapters,
|
chapters = syncOptions.chapters,
|
||||||
tracking = syncOptions.tracking,
|
tracking = syncOptions.tracking,
|
||||||
history = syncOptions.history,
|
history = syncOptions.history,
|
||||||
extensionRepoSettings = syncOptions.extensionRepoSettings,
|
extensionStores = syncOptions.extensionStores,
|
||||||
appSettings = syncOptions.appSettings,
|
appSettings = syncOptions.appSettings,
|
||||||
sourceSettings = syncOptions.sourceSettings,
|
sourceSettings = syncOptions.sourceSettings,
|
||||||
privateSettings = syncOptions.privateSettings,
|
privateSettings = syncOptions.privateSettings,
|
||||||
|
|
@ -107,8 +107,8 @@ class SyncManager(
|
||||||
backupCategories = backupCreator.backupCategories(backupOptions),
|
backupCategories = backupCreator.backupCategories(backupOptions),
|
||||||
backupSources = backupCreator.backupSources(backupManga),
|
backupSources = backupCreator.backupSources(backupManga),
|
||||||
backupPreferences = backupCreator.backupAppPreferences(backupOptions),
|
backupPreferences = backupCreator.backupAppPreferences(backupOptions),
|
||||||
backupExtensionRepo = backupCreator.backupExtensionRepos(backupOptions),
|
|
||||||
backupSourcePreferences = backupCreator.backupSourcePreferences(backupOptions),
|
backupSourcePreferences = backupCreator.backupSourcePreferences(backupOptions),
|
||||||
|
backupExtensionStores = backupCreator.backupExtensionStores(backupOptions),
|
||||||
|
|
||||||
// SY -->
|
// SY -->
|
||||||
backupSavedSearches = backupCreator.backupSavedSearches(backupOptions),
|
backupSavedSearches = backupCreator.backupSavedSearches(backupOptions),
|
||||||
|
|
@ -192,7 +192,7 @@ class SyncManager(
|
||||||
backupSources = remoteBackup.backupSources,
|
backupSources = remoteBackup.backupSources,
|
||||||
backupPreferences = remoteBackup.backupPreferences,
|
backupPreferences = remoteBackup.backupPreferences,
|
||||||
backupSourcePreferences = remoteBackup.backupSourcePreferences,
|
backupSourcePreferences = remoteBackup.backupSourcePreferences,
|
||||||
backupExtensionRepo = remoteBackup.backupExtensionRepo,
|
backupExtensionStores = remoteBackup.backupExtensionStores,
|
||||||
|
|
||||||
// SY -->
|
// SY -->
|
||||||
backupSavedSearches = remoteBackup.backupSavedSearches,
|
backupSavedSearches = remoteBackup.backupSavedSearches,
|
||||||
|
|
@ -222,7 +222,7 @@ class SyncManager(
|
||||||
appSettings = true,
|
appSettings = true,
|
||||||
sourceSettings = true,
|
sourceSettings = true,
|
||||||
libraryEntries = 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.backup.models.Backup
|
||||||
import eu.kanade.tachiyomi.data.sync.SyncNotifier
|
import eu.kanade.tachiyomi.data.sync.SyncNotifier
|
||||||
import eu.kanade.tachiyomi.network.GET
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import eu.kanade.tachiyomi.network.POST
|
||||||
import eu.kanade.tachiyomi.network.PUT
|
import eu.kanade.tachiyomi.network.PUT
|
||||||
import eu.kanade.tachiyomi.network.await
|
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.SerializationException
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.protobuf.ProtoBuf
|
import kotlinx.serialization.protobuf.ProtoBuf
|
||||||
|
|
@ -21,7 +27,6 @@ import tachiyomi.core.common.i18n.stringResource
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
import uy.kohesive.injekt.Injekt
|
import uy.kohesive.injekt.Injekt
|
||||||
import uy.kohesive.injekt.api.get
|
import uy.kohesive.injekt.api.get
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
|
|
||||||
class SyncYomiSyncService(
|
class SyncYomiSyncService(
|
||||||
context: Context,
|
context: Context,
|
||||||
|
|
@ -32,9 +37,34 @@ class SyncYomiSyncService(
|
||||||
private val protoBuf: ProtoBuf = Injekt.get(),
|
private val protoBuf: ProtoBuf = Injekt.get(),
|
||||||
) : SyncService(context, json, syncPreferences) {
|
) : SyncService(context, json, syncPreferences) {
|
||||||
|
|
||||||
|
// KMK -->
|
||||||
|
companion object {
|
||||||
|
private val client by lazy { OkHttpClient() }
|
||||||
|
}
|
||||||
|
// KMK <--
|
||||||
|
|
||||||
private class SyncYomiException(message: String?) : Exception(message)
|
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? {
|
override suspend fun doSync(syncData: SyncData): Backup? {
|
||||||
|
reportSyncEvent(SyncEventStatus.SYNC_STARTED)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val (remoteData, etag) = pullSyncData()
|
val (remoteData, etag) = pullSyncData()
|
||||||
|
|
||||||
|
|
@ -52,11 +82,26 @@ class SyncYomiSyncService(
|
||||||
syncData
|
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
|
return finalSyncData.backup
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
if (e is CancellationException) {
|
||||||
|
reportSyncEvent(SyncEventStatus.SYNC_CANCELLED, e.message)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
logcat(LogPriority.ERROR) { "Error syncing: ${e.message}" }
|
logcat(LogPriority.ERROR) { "Error syncing: ${e.message}" }
|
||||||
notifier.showSyncError(e.message)
|
notifier.showSyncError(e.message)
|
||||||
|
reportSyncEvent(SyncEventStatus.SYNC_ERROR, e.message)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -78,7 +123,6 @@ class SyncYomiSyncService(
|
||||||
headers = headers,
|
headers = headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
val client = OkHttpClient()
|
|
||||||
val response = client.newCall(downloadRequest).await()
|
val response = client.newCall(downloadRequest).await()
|
||||||
|
|
||||||
if (response.code == HttpStatus.SC_NOT_MODIFIED) {
|
if (response.code == HttpStatus.SC_NOT_MODIFIED) {
|
||||||
|
|
@ -123,13 +167,12 @@ class SyncYomiSyncService(
|
||||||
/**
|
/**
|
||||||
* Return true if update success
|
* Return true if update success
|
||||||
*/
|
*/
|
||||||
private suspend fun pushSyncData(syncData: SyncData, eTag: String) {
|
private suspend fun pushSyncData(syncData: SyncData, eTag: String): Boolean {
|
||||||
val backup = syncData.backup ?: return
|
val backup = syncData.backup ?: return true
|
||||||
|
|
||||||
val host = syncPreferences.clientHost().get()
|
val host = syncPreferences.clientHost().get()
|
||||||
val apiKey = syncPreferences.clientAPIKey().get()
|
val apiKey = syncPreferences.clientAPIKey().get()
|
||||||
val uploadUrl = "$host/api/sync/content"
|
val uploadUrl = "$host/api/sync/content"
|
||||||
val timeout = 30L
|
|
||||||
|
|
||||||
val headersBuilder = Headers.Builder().add("X-API-Token", apiKey)
|
val headersBuilder = Headers.Builder().add("X-API-Token", apiKey)
|
||||||
if (eTag.isNotEmpty()) {
|
if (eTag.isNotEmpty()) {
|
||||||
|
|
@ -137,13 +180,6 @@ class SyncYomiSyncService(
|
||||||
}
|
}
|
||||||
val headers = headersBuilder.build()
|
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)
|
val byteArray = protoBuf.encodeToByteArray(Backup.serializer(), backup)
|
||||||
if (byteArray.isEmpty()) {
|
if (byteArray.isEmpty()) {
|
||||||
throw IllegalStateException(context.stringResource(MR.strings.empty_backup_error))
|
throw IllegalStateException(context.stringResource(MR.strings.empty_backup_error))
|
||||||
|
|
@ -156,20 +192,58 @@ class SyncYomiSyncService(
|
||||||
body = body,
|
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) {
|
private suspend fun reportSyncEvent(event: SyncEventStatus, message: String? = null) {
|
||||||
val newETag = response.headers["ETag"]
|
withContext(NonCancellable) {
|
||||||
.takeIf { it?.isNotEmpty() == true } ?: throw SyncYomiException("Missing ETag")
|
try {
|
||||||
syncPreferences.lastSyncEtag().set(newETag)
|
val host = syncPreferences.clientHost().get()
|
||||||
logcat(LogPriority.DEBUG) { "SyncYomi sync completed" }
|
val apiKey = syncPreferences.clientAPIKey().get()
|
||||||
} else if (response.code == HttpStatus.SC_PRECONDITION_FAILED) {
|
val url = "$host/api/sync/event"
|
||||||
// other clients updated remote data, will try next time
|
|
||||||
logcat(LogPriority.DEBUG) { "SyncYomi sync failed with 412" }
|
val headersBuilder = Headers.Builder().add("X-API-Token", apiKey)
|
||||||
} else {
|
val headers = headersBuilder.build()
|
||||||
val responseBody = response.body.string()
|
|
||||||
notifier.showSyncError("Failed to upload sync data: $responseBody")
|
val bodyObj = SyncEvent(
|
||||||
logcat(LogPriority.ERROR) { "SyncError: $responseBody" }
|
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.database.models.Track
|
||||||
import eu.kanade.tachiyomi.data.track.anilist.dto.ALAddMangaResult
|
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.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.ALIdSearchResult
|
||||||
import eu.kanade.tachiyomi.data.track.anilist.dto.ALMangaMetadata
|
import eu.kanade.tachiyomi.data.track.anilist.dto.ALMangaMetadata
|
||||||
import eu.kanade.tachiyomi.data.track.anilist.dto.ALOAuth
|
import eu.kanade.tachiyomi.data.track.anilist.dto.ALOAuth
|
||||||
|
|
@ -26,6 +27,7 @@ import kotlinx.serialization.json.put
|
||||||
import kotlinx.serialization.json.putJsonObject
|
import kotlinx.serialization.json.putJsonObject
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import okhttp3.Response
|
||||||
import tachiyomi.core.common.util.lang.withIOContext
|
import tachiyomi.core.common.util.lang.withIOContext
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
@ -43,6 +45,25 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||||
.rateLimit(permits = 85, period = 1.minutes)
|
.rateLimit(permits = 85, period = 1.minutes)
|
||||||
.build()
|
.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 {
|
suspend fun addLibManga(track: Track): Track {
|
||||||
return withIOContext {
|
return withIOContext {
|
||||||
val query = $$"""
|
val query = $$"""
|
||||||
|
|
@ -71,6 +92,9 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.awaitSuccess()
|
.awaitSuccess()
|
||||||
|
// KMK -->
|
||||||
|
.also { it.parseALError() }
|
||||||
|
// KMK <--
|
||||||
.parseAs<ALAddMangaResult>()
|
.parseAs<ALAddMangaResult>()
|
||||||
.let {
|
.let {
|
||||||
track.library_id = it.data.entry.id
|
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)))
|
authClient.newCall(POST(API_URL, body = payload.toString().toRequestBody(jsonMime)))
|
||||||
.awaitSuccess()
|
.awaitSuccess()
|
||||||
|
// KMK -->
|
||||||
|
.use { it.parseALError() }
|
||||||
|
// KMK <--
|
||||||
track
|
track
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -134,6 +161,9 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||||
}
|
}
|
||||||
authClient.newCall(POST(API_URL, body = payload.toString().toRequestBody(jsonMime)))
|
authClient.newCall(POST(API_URL, body = payload.toString().toRequestBody(jsonMime)))
|
||||||
.awaitSuccess()
|
.awaitSuccess()
|
||||||
|
// KMK -->
|
||||||
|
.use { it.parseALError() }
|
||||||
|
// KMK <--
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -192,6 +222,9 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.awaitSuccess()
|
.awaitSuccess()
|
||||||
|
// KMK -->
|
||||||
|
.also { it.parseALError() }
|
||||||
|
// KMK <--
|
||||||
.parseAs<ALSearchResult>()
|
.parseAs<ALSearchResult>()
|
||||||
.data.page.media
|
.data.page.media
|
||||||
.map { it.toALManga().toTrack() }
|
.map { it.toALManga().toTrack() }
|
||||||
|
|
@ -271,6 +304,9 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.awaitSuccess()
|
.awaitSuccess()
|
||||||
|
// KMK -->
|
||||||
|
.also { it.parseALError() }
|
||||||
|
// KMK <--
|
||||||
.parseAs<ALUserListMangaQueryResult>()
|
.parseAs<ALUserListMangaQueryResult>()
|
||||||
.data.page.mediaList
|
.data.page.mediaList
|
||||||
.map { it.toALUserManga() }
|
.map { it.toALUserManga() }
|
||||||
|
|
@ -312,6 +348,9 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.awaitSuccess()
|
.awaitSuccess()
|
||||||
|
// KMK -->
|
||||||
|
.also { it.parseALError() }
|
||||||
|
// KMK <--
|
||||||
.parseAs<ALCurrentUserResult>()
|
.parseAs<ALCurrentUserResult>()
|
||||||
.let {
|
.let {
|
||||||
val viewer = it.data.viewer
|
val viewer = it.data.viewer
|
||||||
|
|
@ -323,9 +362,9 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||||
|
|
||||||
suspend fun getMangaMetadata(track: DomainTrack): TrackMangaMetadata {
|
suspend fun getMangaMetadata(track: DomainTrack): TrackMangaMetadata {
|
||||||
return withIOContext {
|
return withIOContext {
|
||||||
val query = """
|
val query = $$"""
|
||||||
|query (${'$'}mangaId: Int!) {
|
|query ($mangaId: Int!) {
|
||||||
|Media (id: ${'$'}mangaId) {
|
|Media (id: $mangaId) {
|
||||||
|id
|
|id
|
||||||
|title {
|
|title {
|
||||||
|userPreferred
|
|userPreferred
|
||||||
|
|
@ -365,9 +404,12 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.awaitSuccess()
|
.awaitSuccess()
|
||||||
|
// KMK -->
|
||||||
|
.also { it.parseALError() }
|
||||||
|
// KMK <--
|
||||||
.parseAs<ALMangaMetadata>()
|
.parseAs<ALMangaMetadata>()
|
||||||
.let {
|
.let { metadata ->
|
||||||
val media = it.data.media
|
val media = metadata.data.media
|
||||||
TrackMangaMetadata(
|
TrackMangaMetadata(
|
||||||
remoteId = media.id,
|
remoteId = media.id,
|
||||||
title = media.title.userPreferred,
|
title = media.title.userPreferred,
|
||||||
|
|
@ -392,9 +434,9 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||||
// SY -->
|
// SY -->
|
||||||
suspend fun searchById(id: String): TrackSearch {
|
suspend fun searchById(id: String): TrackSearch {
|
||||||
return withIOContext {
|
return withIOContext {
|
||||||
val query = """
|
val query = $$"""
|
||||||
|query (${'$'}mangaId: Int!) {
|
|query ($mangaId: Int!) {
|
||||||
|Media (id: ${'$'}mangaId) {
|
|Media (id: $mangaId) {
|
||||||
|id
|
|id
|
||||||
|title {
|
|title {
|
||||||
|userPreferred
|
|userPreferred
|
||||||
|
|
@ -430,6 +472,9 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.awaitSuccess()
|
.awaitSuccess()
|
||||||
|
// KMK -->
|
||||||
|
.also { it.parseALError() }
|
||||||
|
// KMK <--
|
||||||
.parseAs<ALIdSearchResult>()
|
.parseAs<ALIdSearchResult>()
|
||||||
.data.media
|
.data.media
|
||||||
.toALManga()
|
.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.data.track.myanimelist.dto.MALUser
|
||||||
import eu.kanade.tachiyomi.network.DELETE
|
import eu.kanade.tachiyomi.network.DELETE
|
||||||
import eu.kanade.tachiyomi.network.GET
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import eu.kanade.tachiyomi.network.HttpException
|
||||||
import eu.kanade.tachiyomi.network.POST
|
import eu.kanade.tachiyomi.network.POST
|
||||||
|
import eu.kanade.tachiyomi.network.await
|
||||||
import eu.kanade.tachiyomi.network.awaitSuccess
|
import eu.kanade.tachiyomi.network.awaitSuccess
|
||||||
import eu.kanade.tachiyomi.network.parseAs
|
import eu.kanade.tachiyomi.network.parseAs
|
||||||
import eu.kanade.tachiyomi.util.PkceUtil
|
import eu.kanade.tachiyomi.util.PkceUtil
|
||||||
|
|
@ -124,8 +126,23 @@ class MyAnimeListApi(
|
||||||
.put(formBodyBuilder.build())
|
.put(formBodyBuilder.build())
|
||||||
.build()
|
.build()
|
||||||
with(json) {
|
with(json) {
|
||||||
authClient.newCall(request)
|
val response = authClient
|
||||||
.awaitSuccess()
|
.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>()
|
.parseAs<MALListItemStatus>()
|
||||||
.let { parseMangaItem(it, track) }
|
.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 MALTokenRefreshFailed : IOException("MAL: Failed to refresh account token")
|
||||||
class MALTokenExpired : IOException("MAL: Login has expired")
|
class MALTokenExpired : IOException("MAL: Login has expired")
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,8 @@ class AppModule(val app: Application) : InjektModule {
|
||||||
Json {
|
Json {
|
||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
explicitNulls = false
|
explicitNulls = false
|
||||||
|
// Gitea/Forgejo often uses JSON null for empty release body or assets array.
|
||||||
|
coerceInputValues = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
addSingletonFactory {
|
addSingletonFactory {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import tachiyomi.domain.backup.service.BackupPreferences
|
||||||
import tachiyomi.domain.download.service.DownloadPreferences
|
import tachiyomi.domain.download.service.DownloadPreferences
|
||||||
import tachiyomi.domain.history.service.HistoryPreferences
|
import tachiyomi.domain.history.service.HistoryPreferences
|
||||||
import tachiyomi.domain.library.service.LibraryPreferences
|
import tachiyomi.domain.library.service.LibraryPreferences
|
||||||
|
import tachiyomi.domain.reading.service.ReadingEtaPreferences
|
||||||
import tachiyomi.domain.storage.service.StoragePreferences
|
import tachiyomi.domain.storage.service.StoragePreferences
|
||||||
import tachiyomi.domain.updates.service.UpdatesPreferences
|
import tachiyomi.domain.updates.service.UpdatesPreferences
|
||||||
import uy.kohesive.injekt.api.InjektModule
|
import uy.kohesive.injekt.api.InjektModule
|
||||||
|
|
@ -61,6 +62,9 @@ class PreferenceModule(val app: Application) : InjektModule {
|
||||||
addSingletonFactory {
|
addSingletonFactory {
|
||||||
ReaderPreferences(get())
|
ReaderPreferences(get())
|
||||||
}
|
}
|
||||||
|
addSingletonFactory {
|
||||||
|
ReadingEtaPreferences(get())
|
||||||
|
}
|
||||||
addSingletonFactory {
|
addSingletonFactory {
|
||||||
TrackPreferences(get())
|
TrackPreferences(get())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -200,7 +200,7 @@ class ExtensionManager(
|
||||||
availableExtensionMapFlow.value = extensions.associateBy {
|
availableExtensionMapFlow.value = extensions.associateBy {
|
||||||
it.pkgName +
|
it.pkgName +
|
||||||
// KMK -->
|
// KMK -->
|
||||||
":${it.signatureHash}"
|
"_${it.signatureHash}"
|
||||||
// KMK <--
|
// KMK <--
|
||||||
}
|
}
|
||||||
updatedInstalledExtensionsStatuses(extensions)
|
updatedInstalledExtensionsStatuses(extensions)
|
||||||
|
|
@ -273,14 +273,14 @@ class ExtensionManager(
|
||||||
// Ext found: Update installed extensions with new information from repo
|
// Ext found: Update installed extensions with new information from repo
|
||||||
// Also clear isObsolete and set new repo Name if needed
|
// Also clear isObsolete and set new repo Name if needed
|
||||||
val hasUpdate = extension.updateExists(availableExt)
|
val hasUpdate = extension.updateExists(availableExt)
|
||||||
|
// KMK -->
|
||||||
installedExtensionsMap[pkgName] = extension.copy(
|
installedExtensionsMap[pkgName] = extension.copy(
|
||||||
hasUpdate = hasUpdate,
|
hasUpdate = hasUpdate,
|
||||||
repoUrl = availableExt.repoUrl,
|
store = availableExt.store,
|
||||||
// KMK -->
|
|
||||||
isObsolete = false,
|
isObsolete = false,
|
||||||
repoName = extension.repoName ?: availableExt.repoName,
|
storeName = extension.storeName ?: availableExt.storeName,
|
||||||
// KMK <--
|
|
||||||
)
|
)
|
||||||
|
// KMK <--
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -298,7 +298,7 @@ class ExtensionManager(
|
||||||
* @param extension The extension to be installed.
|
* @param extension The extension to be installed.
|
||||||
*/
|
*/
|
||||||
fun installExtension(extension: Extension.Available): Flow<InstallStep> {
|
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[
|
val availableExt = availableExtensionMapFlow.value[
|
||||||
extension.pkgName +
|
extension.pkgName +
|
||||||
// KMK -->
|
// KMK -->
|
||||||
":${extension.signatureHash}",
|
"_${extension.signatureHash}",
|
||||||
// KMK <--
|
// KMK <--
|
||||||
] ?: return emptyFlow()
|
] ?: return emptyFlow()
|
||||||
return installExtension(availableExt)
|
return installExtension(availableExt)
|
||||||
|
|
@ -322,7 +322,7 @@ class ExtensionManager(
|
||||||
installer.cancelInstall(
|
installer.cancelInstall(
|
||||||
extension.pkgName +
|
extension.pkgName +
|
||||||
// KMK -->
|
// KMK -->
|
||||||
":${extension.signatureHash}",
|
"_${extension.signatureHash}",
|
||||||
// KMK <--
|
// KMK <--
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,24 +6,13 @@ import eu.kanade.tachiyomi.extension.ExtensionManager
|
||||||
import eu.kanade.tachiyomi.extension.model.Extension
|
import eu.kanade.tachiyomi.extension.model.Extension
|
||||||
import eu.kanade.tachiyomi.extension.model.LoadResult
|
import eu.kanade.tachiyomi.extension.model.LoadResult
|
||||||
import eu.kanade.tachiyomi.extension.util.ExtensionLoader
|
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.BlacklistedSources
|
||||||
import exh.source.ExhPreferences
|
import exh.source.ExhPreferences
|
||||||
import kotlinx.coroutines.async
|
import mihon.domain.extension.interactor.UpdateExtensionStores
|
||||||
import kotlinx.coroutines.awaitAll
|
import mihon.domain.extension.repository.ExtensionStoreRepository
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import logcat.LogPriority
|
|
||||||
import mihon.domain.extensionrepo.interactor.GetExtensionRepo
|
|
||||||
import mihon.domain.extensionrepo.interactor.UpdateExtensionRepo
|
|
||||||
import mihon.domain.extensionrepo.model.ExtensionRepo
|
|
||||||
import tachiyomi.core.common.preference.Preference
|
import tachiyomi.core.common.preference.Preference
|
||||||
import tachiyomi.core.common.preference.PreferenceStore
|
import tachiyomi.core.common.preference.PreferenceStore
|
||||||
import tachiyomi.core.common.util.lang.withIOContext
|
import tachiyomi.core.common.util.lang.withIOContext
|
||||||
import tachiyomi.core.common.util.system.logcat
|
|
||||||
import uy.kohesive.injekt.Injekt
|
import uy.kohesive.injekt.Injekt
|
||||||
import uy.kohesive.injekt.api.get
|
import uy.kohesive.injekt.api.get
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
@ -32,19 +21,16 @@ import kotlin.time.Duration.Companion.days
|
||||||
|
|
||||||
internal class ExtensionApi {
|
internal class ExtensionApi {
|
||||||
|
|
||||||
private val networkService: NetworkHelper by injectLazy()
|
private val repository: ExtensionStoreRepository by injectLazy()
|
||||||
|
|
||||||
private val preferenceStore: PreferenceStore by injectLazy()
|
private val preferenceStore: PreferenceStore by injectLazy()
|
||||||
private val getExtensionRepo: GetExtensionRepo by injectLazy()
|
private val updateExtensionStores: UpdateExtensionStores by injectLazy()
|
||||||
private val updateExtensionRepo: UpdateExtensionRepo by injectLazy()
|
|
||||||
private val extensionManager: ExtensionManager by injectLazy()
|
private val extensionManager: ExtensionManager by injectLazy()
|
||||||
|
|
||||||
// SY -->
|
// SY -->
|
||||||
private val sourcePreferences: SourcePreferences by injectLazy()
|
private val sourcePreferences: SourcePreferences by injectLazy()
|
||||||
|
|
||||||
// SY <--
|
// SY <--
|
||||||
|
|
||||||
private val json: Json by injectLazy()
|
|
||||||
|
|
||||||
private val lastExtCheck: Preference<Long> by lazy {
|
private val lastExtCheck: Preference<Long> by lazy {
|
||||||
preferenceStore.getLong(Preference.appStateKey("last_ext_check"), 0)
|
preferenceStore.getLong(Preference.appStateKey("last_ext_check"), 0)
|
||||||
}
|
}
|
||||||
|
|
@ -54,37 +40,11 @@ internal class ExtensionApi {
|
||||||
val disabledRepos = sourcePreferences.disabledRepos().get()
|
val disabledRepos = sourcePreferences.disabledRepos().get()
|
||||||
// KMK <--
|
// KMK <--
|
||||||
return withIOContext {
|
return withIOContext {
|
||||||
getExtensionRepo.getAll()
|
repository.fetchExtensions(
|
||||||
// KMK -->
|
// KMK -->
|
||||||
.filterNot { it.baseUrl in disabledRepos }
|
disabledRepos,
|
||||||
// KMK <--
|
// 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
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update extension repo details
|
updateExtensionStores()
|
||||||
updateExtensionRepo.awaitAll()
|
|
||||||
|
|
||||||
val extensions = if (fromAvailableExtensionList) {
|
val extensions = if (fromAvailableExtensionList) {
|
||||||
extensionManager.availableExtensionsFlow.value
|
extensionManager.availableExtensionsFlow.value
|
||||||
|
|
@ -138,47 +97,6 @@ internal class ExtensionApi {
|
||||||
return extensionsWithUpdate
|
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 -->
|
// SY -->
|
||||||
private fun Extension.isBlacklisted(
|
private fun Extension.isBlacklisted(
|
||||||
blacklistEnabled: Boolean = sourcePreferences.enableSourceBlacklist().get(),
|
blacklistEnabled: Boolean = sourcePreferences.enableSourceBlacklist().get(),
|
||||||
|
|
@ -194,32 +112,3 @@ internal class ExtensionApi {
|
||||||
}
|
}
|
||||||
// SY <--
|
// 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 val extensionManager: ExtensionManager by injectLazy()
|
||||||
|
|
||||||
private var waitingInstall = AtomicReference<Entry?>(null)
|
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() {
|
private val cancelReceiver = object : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
|
@ -104,7 +106,9 @@ abstract class Installer(private val service: Service) {
|
||||||
}
|
}
|
||||||
val nextEntry = queue.first()
|
val nextEntry = queue.first()
|
||||||
if (waitingInstall.compareAndSet(null, nextEntry)) {
|
if (waitingInstall.compareAndSet(null, nextEntry)) {
|
||||||
queue.removeAt(0)
|
// KMK -->
|
||||||
|
queue.remove(nextEntry)
|
||||||
|
// KMK <--
|
||||||
processEntry(nextEntry)
|
processEntry(nextEntry)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -129,7 +133,11 @@ abstract class Installer(private val service: Service) {
|
||||||
*/
|
*/
|
||||||
private fun cancelQueue(downloadId: Long) {
|
private fun cancelQueue(downloadId: Long) {
|
||||||
val waitingInstall = this.waitingInstall.load()
|
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)) {
|
if (cancelEntry(toCancel)) {
|
||||||
queue.remove(toCancel)
|
queue.remove(toCancel)
|
||||||
if (waitingInstall == toCancel) {
|
if (waitingInstall == toCancel) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package eu.kanade.tachiyomi.extension.installer
|
package eu.kanade.tachiyomi.extension.installer
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.BroadcastReceiver
|
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.lang.use
|
||||||
import eu.kanade.tachiyomi.util.system.getParcelableExtraCompat
|
import eu.kanade.tachiyomi.util.system.getParcelableExtraCompat
|
||||||
import eu.kanade.tachiyomi.util.system.getUriSize
|
import eu.kanade.tachiyomi.util.system.getUriSize
|
||||||
import logcat.LogPriority
|
import exh.log.xLogE
|
||||||
import tachiyomi.core.common.util.system.logcat
|
|
||||||
|
|
||||||
class PackageInstallerInstaller(private val service: Service) : Installer(service) {
|
class PackageInstallerInstaller(private val service: Service) : Installer(service) {
|
||||||
|
|
||||||
|
|
@ -41,7 +41,7 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic
|
||||||
.sanitizeByFiltering(this)
|
.sanitizeByFiltering(this)
|
||||||
}
|
}
|
||||||
if (userAction == null) {
|
if (userAction == null) {
|
||||||
logcat(LogPriority.ERROR) { "Fatal error for $intent" }
|
xLogE("Fatal error for $intent")
|
||||||
continueQueue(InstallStep.Error)
|
continueQueue(InstallStep.Error)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -82,6 +82,7 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic
|
||||||
inputStream.copyTo(outputStream)
|
inputStream.copyTo(outputStream)
|
||||||
session.fsync(outputStream)
|
session.fsync(outputStream)
|
||||||
}
|
}
|
||||||
|
service.contentResolver.delete(entry.uri, null, null)
|
||||||
|
|
||||||
val intentSender = PendingIntent.getBroadcast(
|
val intentSender = PendingIntent.getBroadcast(
|
||||||
service,
|
service,
|
||||||
|
|
@ -89,10 +90,11 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic
|
||||||
Intent(INSTALL_ACTION).setPackage(service.packageName),
|
Intent(INSTALL_ACTION).setPackage(service.packageName),
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0,
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0,
|
||||||
).intentSender
|
).intentSender
|
||||||
|
@SuppressLint("RequestInstallPackagesPolicy")
|
||||||
session.commit(intentSender)
|
session.commit(intentSender)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} 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) ->
|
activeSession?.let { (_, sessionId) ->
|
||||||
packageInstaller.abandonSession(sessionId)
|
packageInstaller.abandonSession(sessionId)
|
||||||
}
|
}
|
||||||
|
|
@ -103,8 +105,13 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic
|
||||||
override fun cancelEntry(entry: Entry): Boolean {
|
override fun cancelEntry(entry: Entry): Boolean {
|
||||||
activeSession?.let { (activeEntry, sessionId) ->
|
activeSession?.let { (activeEntry, sessionId) ->
|
||||||
if (activeEntry == entry) {
|
if (activeEntry == entry) {
|
||||||
packageInstaller.abandonSession(sessionId)
|
return try {
|
||||||
return false
|
packageInstaller.abandonSession(sessionId)
|
||||||
|
false
|
||||||
|
} catch (_: SecurityException) {
|
||||||
|
// Highly likely the session has succeeded
|
||||||
|
true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|
|
||||||
|
|
@ -111,20 +111,17 @@ class ShizukuInstaller(private val service: Service) : Installer(service) {
|
||||||
|
|
||||||
override fun processEntry(entry: Entry) {
|
override fun processEntry(entry: Entry) {
|
||||||
super.processEntry(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 {
|
try {
|
||||||
service.contentResolver.openAssetFileDescriptor(entry.uri, "r")?.use {
|
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 <--
|
// KMK <--
|
||||||
|
service.contentResolver.delete(entry.uri, null, null)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logcat(LogPriority.ERROR, e) { "Failed to install extension ${entry.downloadId} ${entry.uri}" }
|
logcat(LogPriority.ERROR, e) { "Failed to install extension ${entry.downloadId} ${entry.uri}" }
|
||||||
continueQueue(InstallStep.Error)
|
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) {
|
private fun checkInstallationResult(resultCode: Int) {
|
||||||
val downloadId = intent.extras!!.getLong(ExtensionInstaller.EXTRA_DOWNLOAD_ID)
|
val downloadId = intent.extras!!.getLong(ExtensionInstaller.EXTRA_DOWNLOAD_ID)
|
||||||
val extensionManager = Injekt.get<ExtensionManager>()
|
val extensionManager = Injekt.get<ExtensionManager>()
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@ package eu.kanade.tachiyomi.extension.util
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import eu.kanade.domain.base.BasePreferences
|
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.extension.util.ExtensionInstaller.Companion.EXTRA_DOWNLOAD_ID
|
||||||
import eu.kanade.tachiyomi.util.system.getSerializableExtraCompat
|
import eu.kanade.tachiyomi.util.system.getSerializableExtraCompat
|
||||||
import eu.kanade.tachiyomi.util.system.notificationBuilder
|
import eu.kanade.tachiyomi.util.system.notificationBuilder
|
||||||
import logcat.LogPriority
|
import exh.log.xLogE
|
||||||
import tachiyomi.core.common.i18n.stringResource
|
import tachiyomi.core.common.i18n.stringResource
|
||||||
import tachiyomi.core.common.util.system.logcat
|
|
||||||
import tachiyomi.i18n.MR
|
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() {
|
class ExtensionInstallService : Service() {
|
||||||
|
|
||||||
private var installer: Installer? = null
|
private var installer: Installer? = null
|
||||||
|
|
@ -36,7 +42,17 @@ class ExtensionInstallService : Service() {
|
||||||
setContentTitle(stringResource(MR.strings.ext_install_service_notif))
|
setContentTitle(stringResource(MR.strings.ext_install_service_notif))
|
||||||
setProgress(100, 100, true)
|
setProgress(100, 100, true)
|
||||||
}.build()
|
}.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 {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
|
@ -53,7 +69,7 @@ class ExtensionInstallService : Service() {
|
||||||
BasePreferences.ExtensionInstaller.PACKAGEINSTALLER -> PackageInstallerInstaller(this)
|
BasePreferences.ExtensionInstaller.PACKAGEINSTALLER -> PackageInstallerInstaller(this)
|
||||||
BasePreferences.ExtensionInstaller.SHIZUKU -> ShizukuInstaller(this)
|
BasePreferences.ExtensionInstaller.SHIZUKU -> ShizukuInstaller(this)
|
||||||
else -> {
|
else -> {
|
||||||
logcat(LogPriority.ERROR) { "Not implemented for installer $installerUsed" }
|
xLogE("Not implemented for installer $installerUsed")
|
||||||
stopSelf()
|
stopSelf()
|
||||||
return START_NOT_STICKY
|
return START_NOT_STICKY
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,66 +1,52 @@
|
||||||
package eu.kanade.tachiyomi.extension.util
|
package eu.kanade.tachiyomi.extension.util
|
||||||
|
|
||||||
import android.app.DownloadManager
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
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.ContextCompat
|
||||||
import androidx.core.content.getSystemService
|
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import eu.kanade.domain.base.BasePreferences
|
import eu.kanade.domain.base.BasePreferences
|
||||||
import eu.kanade.tachiyomi.extension.ExtensionManager
|
|
||||||
import eu.kanade.tachiyomi.extension.installer.Installer
|
import eu.kanade.tachiyomi.extension.installer.Installer
|
||||||
import eu.kanade.tachiyomi.extension.model.Extension
|
import eu.kanade.tachiyomi.extension.model.Extension
|
||||||
import eu.kanade.tachiyomi.extension.model.InstallStep
|
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.storage.getUriCompat
|
||||||
import eu.kanade.tachiyomi.util.system.isPackageInstalled
|
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.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.flow
|
|
||||||
import kotlinx.coroutines.flow.mapNotNull
|
|
||||||
import kotlinx.coroutines.flow.merge
|
|
||||||
import kotlinx.coroutines.flow.onCompletion
|
import kotlinx.coroutines.flow.onCompletion
|
||||||
import kotlinx.coroutines.flow.transformWhile
|
import kotlinx.coroutines.launch
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import tachiyomi.core.common.util.lang.withUIContext
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import uy.kohesive.injekt.Injekt
|
import uy.kohesive.injekt.Injekt
|
||||||
import uy.kohesive.injekt.api.get
|
import uy.kohesive.injekt.api.get
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The installer which installs, updates and uninstalls the extensions.
|
* The installer which installs, updates and uninstalls the extensions.
|
||||||
*
|
*
|
||||||
* @param context The application context.
|
* @param context The application context.
|
||||||
*/
|
*/
|
||||||
internal class ExtensionInstaller(private val context: 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>>()
|
|
||||||
|
|
||||||
|
// 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 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
|
* Adds the given extension to the downloads queue and returns an observable containing its
|
||||||
* step in the installation process.
|
* step in the installation process.
|
||||||
|
|
@ -71,130 +57,97 @@ internal class ExtensionInstaller(private val context: Context) {
|
||||||
fun downloadAndInstall(url: String, extension: Extension): Flow<InstallStep> {
|
fun downloadAndInstall(url: String, extension: Extension): Flow<InstallStep> {
|
||||||
val pkgName = extension.pkgName +
|
val pkgName = extension.pkgName +
|
||||||
// KMK -->
|
// KMK -->
|
||||||
":${extension.signatureHash}"
|
"_${extension.signatureHash}"
|
||||||
|
val downloadId = pkgName.toDownloadId()
|
||||||
// KMK <--
|
// KMK <--
|
||||||
|
cancelInstall(pkgName)
|
||||||
|
|
||||||
val oldDownload = activeDownloads[pkgName]
|
val step = MutableStateFlow(InstallStep.Pending)
|
||||||
if (oldDownload != null) {
|
activeSteps[downloadId] = step
|
||||||
deleteDownload(pkgName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register the receiver after removing (and unregistering) the previous download
|
val job = scope.launch {
|
||||||
downloadReceiver.register()
|
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()
|
if (!response.isSuccessful) {
|
||||||
val request = DownloadManager.Request(downloadUri)
|
throw Exception("Failed to download extension")
|
||||||
.setTitle(extension.name)
|
}
|
||||||
.setMimeType(APK_MIME)
|
// KMK -->
|
||||||
.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, downloadUri.lastPathSegment)
|
tmpFile.outputStream().use { output ->
|
||||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
response.body.byteStream().use { input ->
|
||||||
|
// KMK <--
|
||||||
|
input.copyTo(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val id = downloadManager.enqueue(request)
|
step.value = InstallStep.Installing
|
||||||
activeDownloads[pkgName] = id
|
installApk(downloadId, tmpFile)
|
||||||
|
} catch (e: Exception) {
|
||||||
val downloadStateFlow = MutableStateFlow(InstallStep.Pending)
|
if (e is InterruptedException) {
|
||||||
downloadsStateFlows[id] = downloadStateFlow
|
// Canceled
|
||||||
|
} else {
|
||||||
// Poll download status
|
logcat(LogPriority.ERROR, e)
|
||||||
val pollStatusFlow = downloadStatusFlow(id).mapNotNull { downloadStatus ->
|
step.value = InstallStep.Error
|
||||||
// Map to our model
|
}
|
||||||
when (downloadStatus) {
|
// KMK -->
|
||||||
DownloadManager.STATUS_PENDING -> InstallStep.Pending
|
tmpFile.delete()
|
||||||
DownloadManager.STATUS_RUNNING -> InstallStep.Downloading
|
// KMK <--
|
||||||
else -> null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return merge(downloadStateFlow, pollStatusFlow).transformWhile {
|
activeJobs[pkgName] = job
|
||||||
emit(it)
|
|
||||||
// Stop when the application is installed or errors
|
return step.asStateFlow()
|
||||||
!it.isCompleted()
|
.onCompletion {
|
||||||
}.onCompletion {
|
activeJobs.remove(pkgName)
|
||||||
// Always notify on main thread
|
activeSteps.remove(downloadId)
|
||||||
withUIContext {
|
job.cancel()
|
||||||
// Always remove the download when unsubscribed
|
|
||||||
deleteDownload(pkgName)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.
|
* 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()) {
|
when (val installer = extensionInstaller.get()) {
|
||||||
BasePreferences.ExtensionInstaller.LEGACY -> {
|
BasePreferences.ExtensionInstaller.LEGACY -> {
|
||||||
val intent = Intent(context, ExtensionInstallActivity::class.java)
|
val intent = Intent(context, ExtensionInstallActivity::class.java)
|
||||||
.setDataAndType(uri, APK_MIME)
|
.setDataAndType(tempFile.getUriCompat(context), APK_MIME)
|
||||||
.putExtra(EXTRA_DOWNLOAD_ID, downloadId)
|
.putExtra(EXTRA_DOWNLOAD_ID, downloadId)
|
||||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
|
||||||
context.startActivity(intent)
|
context.startActivity(intent)
|
||||||
}
|
}
|
||||||
BasePreferences.ExtensionInstaller.PRIVATE -> {
|
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 {
|
try {
|
||||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
|
||||||
tempFile.outputStream().use { output ->
|
|
||||||
input.copyTo(output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ExtensionLoader.installPrivateExtensionFile(context, tempFile)) {
|
if (ExtensionLoader.installPrivateExtensionFile(context, tempFile)) {
|
||||||
extensionManager.updateInstallStep(downloadId, InstallStep.Installed)
|
updateInstallStep(downloadId, InstallStep.Installed)
|
||||||
} else {
|
} else {
|
||||||
extensionManager.updateInstallStep(downloadId, InstallStep.Error)
|
updateInstallStep(downloadId, InstallStep.Error)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logcat(LogPriority.ERROR, e) { "Failed to read downloaded extension file." }
|
logcat(LogPriority.ERROR, e) { "Failed to read downloaded extension file." }
|
||||||
extensionManager.updateInstallStep(downloadId, InstallStep.Error)
|
updateInstallStep(downloadId, InstallStep.Error)
|
||||||
}
|
}
|
||||||
|
|
||||||
tempFile.delete()
|
tempFile.delete()
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
val intent = ExtensionInstallService.getIntent(context, downloadId, uri, installer)
|
val intent = ExtensionInstallService.getIntent(
|
||||||
|
context,
|
||||||
|
downloadId,
|
||||||
|
tempFile.getUriCompat(context),
|
||||||
|
installer,
|
||||||
|
)
|
||||||
ContextCompat.startForegroundService(context, intent)
|
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.
|
* Cancels extension install and remove from download manager and installer.
|
||||||
*/
|
*/
|
||||||
fun cancelInstall(pkgName: String) {
|
fun cancelInstall(pkgName: String) {
|
||||||
val downloadId = activeDownloads.remove(pkgName) ?: return
|
activeJobs.remove(pkgName)?.cancel()
|
||||||
downloadManager.remove(downloadId)
|
Installer.cancelInstallQueue(context, /* KMK --> */ pkgName.toDownloadId() /* KMK <-- */)
|
||||||
Installer.cancelInstallQueue(context, downloadId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -233,91 +185,16 @@ internal class ExtensionInstaller(private val context: Context) {
|
||||||
* @param step New install step.
|
* @param step New install step.
|
||||||
*/
|
*/
|
||||||
fun updateInstallStep(downloadId: Long, step: InstallStep) {
|
fun updateInstallStep(downloadId: Long, step: InstallStep) {
|
||||||
downloadsStateFlows[downloadId]?.let { it.value = step }
|
activeSteps[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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val APK_MIME = "application/vnd.android.package-archive"
|
const val APK_MIME = "application/vnd.android.package-archive"
|
||||||
const val EXTRA_DOWNLOAD_ID = "ExtensionInstaller.extra.DOWNLOAD_ID"
|
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.awaitAll
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import mihon.domain.extensionrepo.interactor.GetExtensionRepo
|
import mihon.domain.extension.interactor.GetExtensionStores
|
||||||
import mihon.domain.extensionrepo.model.ExtensionRepo
|
import mihon.domain.extension.model.ExtensionStore
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
@ -46,7 +46,7 @@ internal object ExtensionLoader {
|
||||||
private val trustExtension: TrustExtension by injectLazy()
|
private val trustExtension: TrustExtension by injectLazy()
|
||||||
|
|
||||||
// KMK -->
|
// KMK -->
|
||||||
private val getExtensionRepo: GetExtensionRepo by injectLazy()
|
private val getExtensionStores: GetExtensionStores by injectLazy()
|
||||||
// KMK <--
|
// KMK <--
|
||||||
|
|
||||||
private val loadNsfwSource by lazy {
|
private val loadNsfwSource by lazy {
|
||||||
|
|
@ -169,7 +169,7 @@ internal object ExtensionLoader {
|
||||||
// Load each extension concurrently and wait for completion
|
// Load each extension concurrently and wait for completion
|
||||||
return runBlocking {
|
return runBlocking {
|
||||||
// KMK -->
|
// KMK -->
|
||||||
val extRepos = getExtensionRepo.getAll()
|
val extStores = getExtensionStores.get()
|
||||||
// KMK <--
|
// KMK <--
|
||||||
val deferred = extPkgs.map {
|
val deferred = extPkgs.map {
|
||||||
async {
|
async {
|
||||||
|
|
@ -177,7 +177,7 @@ internal object ExtensionLoader {
|
||||||
context,
|
context,
|
||||||
it,
|
it,
|
||||||
// KMK -->
|
// KMK -->
|
||||||
extRepos,
|
extStores,
|
||||||
// KMK <--
|
// KMK <--
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -228,7 +228,7 @@ internal object ExtensionLoader {
|
||||||
isShared = true,
|
isShared = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (error: PackageManager.NameNotFoundException) {
|
} catch (_: PackageManager.NameNotFoundException) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -245,11 +245,11 @@ internal object ExtensionLoader {
|
||||||
context: Context,
|
context: Context,
|
||||||
extensionInfo: ExtensionInfo,
|
extensionInfo: ExtensionInfo,
|
||||||
// KMK -->
|
// KMK -->
|
||||||
extRepos: List<ExtensionRepo>? = null,
|
extStores: List<ExtensionStore>? = null,
|
||||||
// KMK <--
|
// KMK <--
|
||||||
): LoadResult {
|
): LoadResult {
|
||||||
// KMK -->
|
// KMK -->
|
||||||
val repos = extRepos ?: getExtensionRepo.getAll()
|
val stores = extStores ?: getExtensionStores.get()
|
||||||
// KMK <--
|
// KMK <--
|
||||||
val pkgManager = context.packageManager
|
val pkgManager = context.packageManager
|
||||||
val pkgInfo = extensionInfo.packageInfo
|
val pkgInfo = extensionInfo.packageInfo
|
||||||
|
|
@ -288,10 +288,10 @@ internal object ExtensionLoader {
|
||||||
libVersion,
|
libVersion,
|
||||||
signatures.last(),
|
signatures.last(),
|
||||||
// KMK -->
|
// KMK -->
|
||||||
repoName = repos.firstOrNull { repo ->
|
storeName = stores.firstOrNull { store ->
|
||||||
signatures.all { it == repo.signingKeyFingerprint }
|
signatures.all { it == store.signingKey }
|
||||||
}?.let { repo ->
|
}?.let { store ->
|
||||||
repo.shortName.takeIf { !it.isNullOrBlank() } ?: repo.name
|
store.badgeLabel.takeIf(String::isNotBlank) ?: store.name
|
||||||
},
|
},
|
||||||
// KMK <--
|
// KMK <--
|
||||||
)
|
)
|
||||||
|
|
@ -358,10 +358,10 @@ internal object ExtensionLoader {
|
||||||
isShared = extensionInfo.isShared,
|
isShared = extensionInfo.isShared,
|
||||||
// KMK -->
|
// KMK -->
|
||||||
signatureHash = signatures.last(),
|
signatureHash = signatures.last(),
|
||||||
repoName = repos.firstOrNull { repo ->
|
storeName = stores.firstOrNull { store ->
|
||||||
signatures.all { it == repo.signingKeyFingerprint }
|
signatures.all { it == store.signingKey }
|
||||||
}?.let { repo ->
|
}?.let { store ->
|
||||||
repo.shortName.takeIf { !it.isNullOrBlank() } ?: repo.name
|
store.badgeLabel.takeIf(String::isNotBlank) ?: store.name
|
||||||
},
|
},
|
||||||
// KMK <--
|
// 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.MergedSource
|
||||||
import eu.kanade.tachiyomi.source.online.all.NHentai
|
import eu.kanade.tachiyomi.source.online.all.NHentai
|
||||||
import eu.kanade.tachiyomi.source.online.english.EightMuses
|
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.Pururin
|
||||||
import eu.kanade.tachiyomi.source.online.english.Tsumino
|
|
||||||
import exh.log.xLogD
|
import exh.log.xLogD
|
||||||
import exh.source.BlacklistedSources
|
import exh.source.BlacklistedSources
|
||||||
import exh.source.DelegatedHttpSource
|
import exh.source.DelegatedHttpSource
|
||||||
|
|
@ -24,10 +22,8 @@ import exh.source.EIGHTMUSES_SOURCE_ID
|
||||||
import exh.source.EXHENTAI_EXT_SOURCES
|
import exh.source.EXHENTAI_EXT_SOURCES
|
||||||
import exh.source.EnhancedHttpSource
|
import exh.source.EnhancedHttpSource
|
||||||
import exh.source.ExhPreferences
|
import exh.source.ExhPreferences
|
||||||
import exh.source.HBROWSE_SOURCE_ID
|
|
||||||
import exh.source.MERGED_SOURCE_ID
|
import exh.source.MERGED_SOURCE_ID
|
||||||
import exh.source.PURURIN_SOURCE_ID
|
import exh.source.PURURIN_SOURCE_ID
|
||||||
import exh.source.TSUMINO_SOURCE_ID
|
|
||||||
import exh.source.handleSourceLibrary
|
import exh.source.handleSourceLibrary
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
|
@ -282,12 +278,6 @@ class AndroidSourceManager(
|
||||||
"eu.kanade.tachiyomi.extension.en.pururin.Pururin",
|
"eu.kanade.tachiyomi.extension.en.pururin.Pururin",
|
||||||
Pururin::class,
|
Pururin::class,
|
||||||
),
|
),
|
||||||
DelegatedSource(
|
|
||||||
"Tsumino",
|
|
||||||
TSUMINO_SOURCE_ID,
|
|
||||||
"eu.kanade.tachiyomi.extension.en.tsumino.Tsumino",
|
|
||||||
Tsumino::class,
|
|
||||||
),
|
|
||||||
DelegatedSource(
|
DelegatedSource(
|
||||||
"MangaDex",
|
"MangaDex",
|
||||||
fillInSourceId,
|
fillInSourceId,
|
||||||
|
|
@ -295,12 +285,6 @@ class AndroidSourceManager(
|
||||||
MangaDex::class,
|
MangaDex::class,
|
||||||
true,
|
true,
|
||||||
),
|
),
|
||||||
DelegatedSource(
|
|
||||||
"HBrowse",
|
|
||||||
HBROWSE_SOURCE_ID,
|
|
||||||
"eu.kanade.tachiyomi.extension.en.hbrowse.HBrowse",
|
|
||||||
HBrowse::class,
|
|
||||||
),
|
|
||||||
DelegatedSource(
|
DelegatedSource(
|
||||||
"8Muses",
|
"8Muses",
|
||||||
EIGHTMUSES_SOURCE_ID,
|
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
|
* 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.
|
* Not really desired but let's accept it.
|
||||||
*/
|
*/
|
||||||
if (!searchSource.isNullOrBlank()) onChangeSearchQuery(searchSource)
|
if (!searchSource.isNullOrBlank()) onChangeSearchQuery(searchSource)
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.onCompletion
|
import kotlinx.coroutines.flow.onCompletion
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.flow.takeWhile
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import tachiyomi.core.common.util.lang.launchIO
|
import tachiyomi.core.common.util.lang.launchIO
|
||||||
|
|
@ -53,7 +54,7 @@ class ExtensionsScreenModel(
|
||||||
map[
|
map[
|
||||||
it.pkgName +
|
it.pkgName +
|
||||||
// KMK -->
|
// KMK -->
|
||||||
":${it.signatureHash}",
|
"_${it.signatureHash}",
|
||||||
// KMK <--
|
// KMK <--
|
||||||
] ?: InstallStep.Idle,
|
] ?: InstallStep.Idle,
|
||||||
)
|
)
|
||||||
|
|
@ -207,7 +208,7 @@ class ExtensionsScreenModel(
|
||||||
it + Pair(
|
it + Pair(
|
||||||
extension.pkgName +
|
extension.pkgName +
|
||||||
// KMK -->
|
// KMK -->
|
||||||
":${extension.signatureHash}",
|
"_${extension.signatureHash}",
|
||||||
// KMK <--
|
// KMK <--
|
||||||
installStep,
|
installStep,
|
||||||
)
|
)
|
||||||
|
|
@ -219,7 +220,7 @@ class ExtensionsScreenModel(
|
||||||
it - (
|
it - (
|
||||||
extension.pkgName +
|
extension.pkgName +
|
||||||
// KMK -->
|
// KMK -->
|
||||||
":${extension.signatureHash}"
|
"_${extension.signatureHash}"
|
||||||
// KMK <--
|
// KMK <--
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -228,6 +229,7 @@ class ExtensionsScreenModel(
|
||||||
private suspend fun Flow<InstallStep>.collectToInstallUpdate(extension: Extension) =
|
private suspend fun Flow<InstallStep>.collectToInstallUpdate(extension: Extension) =
|
||||||
this
|
this
|
||||||
.onEach { installStep -> addDownloadState(extension, installStep) }
|
.onEach { installStep -> addDownloadState(extension, installStep) }
|
||||||
|
.takeWhile { installStep -> installStep != InstallStep.Installed }
|
||||||
.onCompletion { removeDownloadState(extension) }
|
.onCompletion { removeDownloadState(extension) }
|
||||||
.collect()
|
.collect()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ import cafe.adriel.voyager.navigator.currentOrThrow
|
||||||
import eu.kanade.presentation.browse.ExtensionScreen
|
import eu.kanade.presentation.browse.ExtensionScreen
|
||||||
import eu.kanade.presentation.components.AppBar
|
import eu.kanade.presentation.components.AppBar
|
||||||
import eu.kanade.presentation.components.TabContent
|
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.extension.model.Extension
|
||||||
import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionDetailsScreen
|
import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionDetailsScreen
|
||||||
import eu.kanade.tachiyomi.ui.webview.WebViewScreen
|
import eu.kanade.tachiyomi.ui.webview.WebViewScreen
|
||||||
|
|
@ -62,8 +62,8 @@ fun extensionsTab(
|
||||||
onClick = { navigator.push(ExtensionFilterScreen()) },
|
onClick = { navigator.push(ExtensionFilterScreen()) },
|
||||||
),
|
),
|
||||||
AppBar.OverflowAction(
|
AppBar.OverflowAction(
|
||||||
title = stringResource(MR.strings.label_extension_repos),
|
title = stringResource(MR.strings.extensionStores),
|
||||||
onClick = { navigator.push(ExtensionReposScreen()) },
|
onClick = { navigator.push(ExtensionStoresScreen()) },
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
content = { contentPadding, _ ->
|
content = { contentPadding, _ ->
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.core.os.bundleOf
|
|
||||||
import androidx.fragment.app.FragmentActivity
|
import androidx.fragment.app.FragmentActivity
|
||||||
import androidx.fragment.app.FragmentContainerView
|
import androidx.fragment.app.FragmentContainerView
|
||||||
import androidx.fragment.app.FragmentManager
|
import androidx.fragment.app.FragmentManager
|
||||||
|
|
@ -182,7 +181,9 @@ class SourcePreferencesFragment : PreferenceFragmentCompat() {
|
||||||
|
|
||||||
fun getInstance(sourceId: Long): SourcePreferencesFragment {
|
fun getInstance(sourceId: Long): SourcePreferencesFragment {
|
||||||
return SourcePreferencesFragment().apply {
|
return SourcePreferencesFragment().apply {
|
||||||
arguments = bundleOf(SOURCE_ID to sourceId)
|
arguments = Bundle().apply {
|
||||||
|
putLong(SOURCE_ID, sourceId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -128,11 +128,9 @@ data class MigrateMangaScreen(
|
||||||
// KMK -->
|
// KMK -->
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
MigrateMangaBottomBar(
|
MigrateMangaBottomBar(
|
||||||
selected = state.selection,
|
selectionMode = state.selectionMode,
|
||||||
onMultiMigrateClicked = {
|
onMultiMigrateClicked = {
|
||||||
val selection = state.selection
|
navigator.push(MigrationConfigScreen(state.selection))
|
||||||
.map { it.manga.id }
|
|
||||||
navigator.push(MigrationConfigScreen(selection))
|
|
||||||
},
|
},
|
||||||
enableScrollToTop = enableScrollToTop,
|
enableScrollToTop = enableScrollToTop,
|
||||||
enableScrollToBottom = enableScrollToBottom,
|
enableScrollToBottom = enableScrollToBottom,
|
||||||
|
|
@ -187,7 +185,7 @@ data class MigrateMangaScreen(
|
||||||
contentPadding: PaddingValues,
|
contentPadding: PaddingValues,
|
||||||
state: MigrateMangaScreenModel.State,
|
state: MigrateMangaScreenModel.State,
|
||||||
// KMK -->
|
// KMK -->
|
||||||
onMangaSelected: (MigrateMangaItem, Boolean, Boolean, Boolean) -> Unit,
|
onMangaSelected: (Manga, Boolean, Boolean) -> Unit,
|
||||||
// KMK <--
|
// KMK <--
|
||||||
onClickItem: (Manga) -> Unit,
|
onClickItem: (Manga) -> Unit,
|
||||||
onClickCover: (Manga) -> Unit,
|
onClickCover: (Manga) -> Unit,
|
||||||
|
|
@ -197,20 +195,23 @@ data class MigrateMangaScreen(
|
||||||
contentPadding = contentPadding,
|
contentPadding = contentPadding,
|
||||||
) {
|
) {
|
||||||
items(items = state.titles) { manga ->
|
items(items = state.titles) { manga ->
|
||||||
|
// KMK -->
|
||||||
|
val isSelected = manga.id in state.selection
|
||||||
|
// KMK <--
|
||||||
MigrateMangaItem(
|
MigrateMangaItem(
|
||||||
manga = manga.manga,
|
manga = manga,
|
||||||
isSelected = manga.selected,
|
isSelected = isSelected,
|
||||||
onClickItem = {
|
onClickItem = {
|
||||||
// KMK -->
|
// KMK -->
|
||||||
when {
|
when {
|
||||||
state.selectionMode -> onMangaSelected(manga, !manga.selected, true, false)
|
state.selectionMode -> onMangaSelected(manga, !isSelected, false)
|
||||||
// KMK <--
|
// KMK <--
|
||||||
else -> onClickItem(it)
|
else -> onClickItem(it)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onClickCover = onClickCover,
|
onClickCover = onClickCover,
|
||||||
// KMK -->
|
// KMK -->
|
||||||
onLongClick = { onMangaSelected(manga, !manga.selected, true, true) },
|
onLongClick = { onMangaSelected(manga, !isSelected, true) },
|
||||||
modifier = Modifier.animateItemFastScroll(),
|
modifier = Modifier.animateItemFastScroll(),
|
||||||
// KMK <--
|
// KMK <--
|
||||||
)
|
)
|
||||||
|
|
@ -293,7 +294,7 @@ data class MigrateMangaScreen(
|
||||||
@Composable
|
@Composable
|
||||||
private fun MigrateMangaBottomBar(
|
private fun MigrateMangaBottomBar(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
selected: List<MigrateMangaItem>,
|
selectionMode: Boolean,
|
||||||
onMultiMigrateClicked: () -> Unit,
|
onMultiMigrateClicked: () -> Unit,
|
||||||
enableScrollToTop: Boolean,
|
enableScrollToTop: Boolean,
|
||||||
enableScrollToBottom: Boolean,
|
enableScrollToBottom: Boolean,
|
||||||
|
|
@ -302,7 +303,7 @@ data class MigrateMangaScreen(
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val animatedElevation by animateDpAsState(
|
val animatedElevation by animateDpAsState(
|
||||||
targetValue = if (selected.isNotEmpty()) 3.dp else 0.dp,
|
targetValue = if (selectionMode) 3.dp else 0.dp,
|
||||||
label = "elevation",
|
label = "elevation",
|
||||||
)
|
)
|
||||||
Surface(
|
Surface(
|
||||||
|
|
@ -350,7 +351,7 @@ data class MigrateMangaScreen(
|
||||||
toConfirm = confirm[1],
|
toConfirm = confirm[1],
|
||||||
onLongClick = { onLongClickItem(1) },
|
onLongClick = { onLongClickItem(1) },
|
||||||
onClick = onMultiMigrateClicked,
|
onClick = onMultiMigrateClicked,
|
||||||
enabled = selected.isNotEmpty(),
|
enabled = selectionMode,
|
||||||
)
|
)
|
||||||
Button(
|
Button(
|
||||||
title = stringResource(KMR.strings.action_scroll_to_bottom),
|
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 androidx.compose.runtime.Immutable
|
||||||
import cafe.adriel.voyager.core.model.StateScreenModel
|
import cafe.adriel.voyager.core.model.StateScreenModel
|
||||||
import cafe.adriel.voyager.core.model.screenModelScope
|
import cafe.adriel.voyager.core.model.screenModelScope
|
||||||
import eu.kanade.core.util.addOrRemove
|
|
||||||
import eu.kanade.tachiyomi.source.Source
|
import eu.kanade.tachiyomi.source.Source
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
|
@ -17,6 +16,7 @@ import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
|
import mihon.core.common.utils.mutate
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import tachiyomi.domain.manga.interactor.GetFavorites
|
import tachiyomi.domain.manga.interactor.GetFavorites
|
||||||
import tachiyomi.domain.manga.model.Manga
|
import tachiyomi.domain.manga.model.Manga
|
||||||
|
|
@ -36,7 +36,6 @@ class MigrateMangaScreenModel(
|
||||||
// KMK -->
|
// KMK -->
|
||||||
// First and last selected index in list
|
// First and last selected index in list
|
||||||
private val selectedPositions: Array<Int> = arrayOf(-1, -1)
|
private val selectedPositions: Array<Int> = arrayOf(-1, -1)
|
||||||
private val selectedMangaIds: HashSet<Long> = HashSet()
|
|
||||||
// KMK <--
|
// KMK <--
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
|
@ -50,120 +49,133 @@ class MigrateMangaScreenModel(
|
||||||
logcat(LogPriority.ERROR, it)
|
logcat(LogPriority.ERROR, it)
|
||||||
_events.send(MigrationMangaEvent.FailedFetchingFavorites)
|
_events.send(MigrationMangaEvent.FailedFetchingFavorites)
|
||||||
mutableState.update { state ->
|
mutableState.update { state ->
|
||||||
state.copy(titleList = persistentListOf())
|
state.copy(
|
||||||
|
titleList = persistentListOf(),
|
||||||
|
// KMK -->
|
||||||
|
selection = emptySet(),
|
||||||
|
// KMK <--
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// KMK -->
|
|
||||||
.map { manga ->
|
|
||||||
toMigrationMangaScreenItems(manga)
|
|
||||||
}
|
|
||||||
// KMK <--
|
|
||||||
.map { manga ->
|
.map { manga ->
|
||||||
manga
|
manga
|
||||||
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.manga.title })
|
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.title })
|
||||||
.toImmutableList()
|
.toImmutableList()
|
||||||
}
|
}
|
||||||
.collectLatest { list ->
|
.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(
|
fun toggleSelection(
|
||||||
item: MigrateMangaItem,
|
item: Manga,
|
||||||
|
// KMK -->
|
||||||
selected: Boolean,
|
selected: Boolean,
|
||||||
userSelected: Boolean = false,
|
|
||||||
fromLongPress: Boolean = false,
|
fromLongPress: Boolean = false,
|
||||||
) {
|
) {
|
||||||
mutableState.update { state ->
|
mutableState.update { state ->
|
||||||
val newItems = state.titles.toMutableList().apply {
|
if (item.id in state.selection == selected) return@update state
|
||||||
val selectedIndex = indexOfFirst { it.manga.id == item.manga.id }
|
val selectedIndex = state.titles.indexOfFirst { it.id == item.id }
|
||||||
if (selectedIndex < 0) return@apply
|
if (selectedIndex < 0) return@update state
|
||||||
|
|
||||||
val selectedItem = get(selectedIndex)
|
val selection = state.selection.mutate { list ->
|
||||||
if (selectedItem.selected == selected) return@apply
|
state.titles.run {
|
||||||
|
val firstSelection = list.isEmpty()
|
||||||
|
if (selected) list.add(item.id) else list.remove(item.id)
|
||||||
|
|
||||||
val firstSelection = none { it.selected }
|
if (selected && fromLongPress) {
|
||||||
set(selectedIndex, selectedItem.copy(selected = selected))
|
if (firstSelection) {
|
||||||
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]
|
|
||||||
selectedPositions[0] = selectedIndex
|
selectedPositions[0] = selectedIndex
|
||||||
} else if (selectedIndex > selectedPositions[1]) {
|
|
||||||
range = (selectedPositions[1] + 1) until selectedIndex
|
|
||||||
selectedPositions[1] = selectedIndex
|
selectedPositions[1] = selectedIndex
|
||||||
} else {
|
} else {
|
||||||
// Just select itself
|
// Try to select the items in-between when possible
|
||||||
range = IntRange.EMPTY
|
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 {
|
range.forEach {
|
||||||
val inBetweenItem = get(it)
|
val inBetweenItem = get(it)
|
||||||
if (!inBetweenItem.selected) {
|
if (inBetweenItem.id !in list) {
|
||||||
selectedMangaIds.add(inBetweenItem.manga.id)
|
list.add(inBetweenItem.id)
|
||||||
set(it, inBetweenItem.copy(selected = true))
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} else if (!fromLongPress) {
|
||||||
} else if (userSelected && !fromLongPress) {
|
if (!selected) {
|
||||||
if (!selected) {
|
if (selectedIndex == selectedPositions[0]) {
|
||||||
if (selectedIndex == selectedPositions[0]) {
|
selectedPositions[0] = indexOfFirst { it.id in list }
|
||||||
selectedPositions[0] = indexOfFirst { it.selected }
|
} else if (selectedIndex == selectedPositions[1]) {
|
||||||
} else if (selectedIndex == selectedPositions[1]) {
|
selectedPositions[1] = indexOfLast { it.id in list }
|
||||||
selectedPositions[1] = indexOfLast { it.selected }
|
}
|
||||||
}
|
} else {
|
||||||
} else {
|
if (selectedIndex < selectedPositions[0]) {
|
||||||
if (selectedIndex < selectedPositions[0]) {
|
selectedPositions[0] = selectedIndex
|
||||||
selectedPositions[0] = selectedIndex
|
} else if (selectedIndex > selectedPositions[1]) {
|
||||||
} else if (selectedIndex > selectedPositions[1]) {
|
selectedPositions[1] = selectedIndex
|
||||||
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) {
|
fun toggleAllSelection(selected: Boolean = true) {
|
||||||
mutableState.update { state ->
|
mutableState.update { state ->
|
||||||
val newItems = state.titles.map {
|
val selection = if (selected) {
|
||||||
selectedMangaIds.addOrRemove(it.manga.id, selected)
|
state.titles.mapTo(mutableSetOf()) { it.id }
|
||||||
it.copy(selected = selected)
|
} else {
|
||||||
|
emptySet()
|
||||||
}
|
}
|
||||||
selectedPositions[0] = -1
|
selectedPositions[0] = -1
|
||||||
selectedPositions[1] = -1
|
selectedPositions[1] = -1
|
||||||
state.copy(titleList = newItems.toImmutableList())
|
state.copy(selection = selection)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun invertSelection() {
|
fun invertSelection() {
|
||||||
mutableState.update { state ->
|
mutableState.update { state ->
|
||||||
val newItems = state.titles.map {
|
val selection = state.selection.mutate { list ->
|
||||||
selectedMangaIds.addOrRemove(it.manga.id, !it.selected)
|
state.titles.forEach { item ->
|
||||||
it.copy(selected = !it.selected)
|
if (!list.remove(item.id)) list.add(item.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
selectedPositions[0] = -1
|
selectedPositions[0] = -1
|
||||||
selectedPositions[1] = -1
|
selectedPositions[1] = -1
|
||||||
state.copy(titleList = newItems.toImmutableList())
|
state.copy(selection = selection)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// KMK <--
|
// KMK <--
|
||||||
|
|
@ -177,13 +189,11 @@ class MigrateMangaScreenModel(
|
||||||
@Immutable
|
@Immutable
|
||||||
data class State(
|
data class State(
|
||||||
val source: Source? = null,
|
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()
|
get() = titleList ?: persistentListOf()
|
||||||
|
|
||||||
val isLoading: Boolean
|
val isLoading: Boolean
|
||||||
|
|
@ -199,11 +209,3 @@ class MigrateMangaScreenModel(
|
||||||
sealed interface MigrationMangaEvent {
|
sealed interface MigrationMangaEvent {
|
||||||
data object FailedFetchingFavorites : 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 ->
|
onMangaLongClick = { manga ->
|
||||||
// KMK -->
|
// KMK -->
|
||||||
|
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
if (bulkFavoriteState.selectionMode) {
|
if (bulkFavoriteState.selectionMode) {
|
||||||
navigator.push(MangaScreen(manga.id, true))
|
navigator.push(MangaScreen(manga.id, true))
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -388,7 +389,6 @@ data class BrowseSourceScreen(
|
||||||
)
|
)
|
||||||
else -> screenModel.addFavorite(manga)
|
else -> screenModel.addFavorite(manga)
|
||||||
}
|
}
|
||||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -298,14 +298,14 @@ class HistoryScreenModel(
|
||||||
selectionOptions: HistorySelectionOptions,
|
selectionOptions: HistorySelectionOptions,
|
||||||
) {
|
) {
|
||||||
val (selected, fromLongPress) = selectionOptions
|
val (selected, fromLongPress) = selectionOptions
|
||||||
if (item.chapterId in state.value.selection == selected) return
|
|
||||||
|
|
||||||
mutableState.update { state ->
|
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 ->
|
val selection = state.selection.mutate { list ->
|
||||||
state.list.run {
|
state.list.run {
|
||||||
val selectedIndex = indexOfFirst { it.chapterId == item.chapterId }
|
|
||||||
if (selectedIndex < 0) return@run
|
|
||||||
|
|
||||||
val firstSelection = list.isEmpty()
|
val firstSelection = list.isEmpty()
|
||||||
if (selected) list.add(item.chapterId) else list.remove(item.chapterId)
|
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.interactor.SetMangaCategories
|
||||||
import tachiyomi.domain.category.model.Category
|
import tachiyomi.domain.category.model.Category
|
||||||
import tachiyomi.domain.category.model.Category.Companion.UNCATEGORIZED_ID
|
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.GetChaptersByMangaId
|
||||||
import tachiyomi.domain.chapter.interactor.GetMergedChaptersByMangaId
|
import tachiyomi.domain.chapter.interactor.GetMergedChaptersByMangaId
|
||||||
import tachiyomi.domain.chapter.model.Chapter
|
import tachiyomi.domain.chapter.model.Chapter
|
||||||
import tachiyomi.domain.history.interactor.GetNextChapters
|
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.GetReadDurationEntriesByMangaIds
|
||||||
import tachiyomi.domain.history.interactor.GetTotalReadDuration
|
import tachiyomi.domain.history.interactor.GetTotalReadDuration
|
||||||
import tachiyomi.domain.library.model.LibraryDisplayMode
|
import tachiyomi.domain.library.model.LibraryDisplayMode
|
||||||
|
|
@ -106,7 +110,9 @@ import tachiyomi.domain.library.model.LibraryManga
|
||||||
import tachiyomi.domain.library.model.LibrarySort
|
import tachiyomi.domain.library.model.LibrarySort
|
||||||
import tachiyomi.domain.library.model.sort
|
import tachiyomi.domain.library.model.sort
|
||||||
import tachiyomi.domain.library.service.LibraryPreferences
|
import tachiyomi.domain.library.service.LibraryPreferences
|
||||||
import tachiyomi.domain.reading.ReadingEtaDefaults
|
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.GetIdsOfFavoriteMangaWithMetadata
|
||||||
import tachiyomi.domain.manga.interactor.GetLibraryManga
|
import tachiyomi.domain.manga.interactor.GetLibraryManga
|
||||||
import tachiyomi.domain.manga.interactor.GetMergedMangaById
|
import tachiyomi.domain.manga.interactor.GetMergedMangaById
|
||||||
|
|
@ -137,6 +143,7 @@ class LibraryScreenModel(
|
||||||
private val getTracksPerManga: GetTracksPerManga = Injekt.get(),
|
private val getTracksPerManga: GetTracksPerManga = Injekt.get(),
|
||||||
private val getNextChapters: GetNextChapters = Injekt.get(),
|
private val getNextChapters: GetNextChapters = Injekt.get(),
|
||||||
private val getChaptersByMangaId: GetChaptersByMangaId = Injekt.get(),
|
private val getChaptersByMangaId: GetChaptersByMangaId = Injekt.get(),
|
||||||
|
private val getBookmarkedChaptersByMangaId: GetBookmarkedChaptersByMangaId = Injekt.get(),
|
||||||
private val setReadStatus: SetReadStatus = Injekt.get(),
|
private val setReadStatus: SetReadStatus = Injekt.get(),
|
||||||
private val updateManga: UpdateManga = Injekt.get(),
|
private val updateManga: UpdateManga = Injekt.get(),
|
||||||
private val setMangaCategories: SetMangaCategories = Injekt.get(),
|
private val setMangaCategories: SetMangaCategories = Injekt.get(),
|
||||||
|
|
@ -152,6 +159,8 @@ class LibraryScreenModel(
|
||||||
private val sourcePreferences: SourcePreferences = Injekt.get(),
|
private val sourcePreferences: SourcePreferences = Injekt.get(),
|
||||||
private val getMergedMangaById: GetMergedMangaById = Injekt.get(),
|
private val getMergedMangaById: GetMergedMangaById = Injekt.get(),
|
||||||
private val getReadDurationEntriesByMangaIds: GetReadDurationEntriesByMangaIds = 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 getTotalReadDuration: GetTotalReadDuration = Injekt.get(),
|
||||||
private val getTracks: GetTracks = Injekt.get(),
|
private val getTracks: GetTracks = Injekt.get(),
|
||||||
private val getIdsOfFavoriteMangaWithMetadata: GetIdsOfFavoriteMangaWithMetadata = Injekt.get(),
|
private val getIdsOfFavoriteMangaWithMetadata: GetIdsOfFavoriteMangaWithMetadata = Injekt.get(),
|
||||||
|
|
@ -164,6 +173,7 @@ class LibraryScreenModel(
|
||||||
// SY <--
|
// SY <--
|
||||||
// KMK -->
|
// KMK -->
|
||||||
private val smartSearchMerge: SmartSearchMerge = Injekt.get(),
|
private val smartSearchMerge: SmartSearchMerge = Injekt.get(),
|
||||||
|
private val readingEtaPreferences: ReadingEtaPreferences = Injekt.get(),
|
||||||
// KMK <--
|
// KMK <--
|
||||||
) : StateScreenModel<LibraryScreenModel.State>(State()) {
|
) : StateScreenModel<LibraryScreenModel.State>(State()) {
|
||||||
|
|
||||||
|
|
@ -195,8 +205,9 @@ class LibraryScreenModel(
|
||||||
),
|
),
|
||||||
// KMK <--
|
// KMK <--
|
||||||
getLibraryItemPreferencesFlow(),
|
getLibraryItemPreferencesFlow(),
|
||||||
) { (searchQuery, categories, favorites), (tracksMap, trackingFilters), (includedCategories, excludedCategories), itemPreferences ->
|
readingEtaPreferences.limitsFlow(),
|
||||||
val estimatedTimeLeftByMangaId = computeEstimatedTimeLeftByMangaId(favorites)
|
) { (searchQuery, categories, favorites), (tracksMap, trackingFilters), (includedCategories, excludedCategories), itemPreferences, etaLimits ->
|
||||||
|
val estimatedTimeLeftByMangaId = computeEstimatedTimeLeftByMangaId(favorites, etaLimits)
|
||||||
val favoritesWithEstimatedTimeLeft = favorites.map { item ->
|
val favoritesWithEstimatedTimeLeft = favorites.map { item ->
|
||||||
item.copy(
|
item.copy(
|
||||||
estimatedTimeLeftMillis = if (itemPreferences.estimatedTimeLeftBadge) {
|
estimatedTimeLeftMillis = if (itemPreferences.estimatedTimeLeftBadge) {
|
||||||
|
|
@ -440,63 +451,61 @@ class LibraryScreenModel(
|
||||||
|
|
||||||
private suspend fun computeEstimatedTimeLeftByMangaId(
|
private suspend fun computeEstimatedTimeLeftByMangaId(
|
||||||
favorites: List<LibraryItem>,
|
favorites: List<LibraryItem>,
|
||||||
|
limits: ReadingEtaLimits,
|
||||||
): Map<Long, Long?> {
|
): Map<Long, Long?> {
|
||||||
if (favorites.isEmpty()) return emptyMap()
|
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 ->
|
return favorites.associate { item ->
|
||||||
item.id to calculateEstimatedTimeLeftMs(
|
item.id to computeEstimatedTimeLeftMs(item, limits)
|
||||||
item = item,
|
|
||||||
chapterDurations = chapterDurationsByMangaId[item.id].orEmpty(),
|
|
||||||
globalAverageChapterDurationMs = globalAverageChapterDurationMs,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun calculateEstimatedTimeLeftMs(
|
private suspend fun computeEstimatedTimeLeftMs(
|
||||||
item: LibraryItem,
|
item: LibraryItem,
|
||||||
chapterDurations: List<Long>,
|
limits: ReadingEtaLimits,
|
||||||
globalAverageChapterDurationMs: Long?,
|
|
||||||
): Long? {
|
): Long? {
|
||||||
val unreadCount = item.libraryManga.unreadCount.coerceAtLeast(0L)
|
if (item.libraryManga.unreadCount <= 0L) return null
|
||||||
if (unreadCount == 0L) return null
|
|
||||||
|
|
||||||
val averageChapterDurationMs = calculateAverageChapterDurationMs(chapterDurations)
|
val manga = item.libraryManga.manga
|
||||||
?: globalAverageChapterDurationMs
|
val mergedManga = if (manga.source == MERGED_SOURCE_ID) {
|
||||||
?: return null
|
getMergedMangaById.await(manga.id).associateBy { it.id }
|
||||||
|
|
||||||
val remainingChapterUnits = if (item.libraryManga.totalChapters == 1L) {
|
|
||||||
1L
|
|
||||||
} else {
|
} else {
|
||||||
unreadCount
|
emptyMap()
|
||||||
}
|
}
|
||||||
return (averageChapterDurationMs * remainingChapterUnits).coerceAtLeast(0L)
|
val rawChapters = if (manga.source == MERGED_SOURCE_ID) {
|
||||||
}
|
getMergedChaptersByMangaId.await(manga.id, applyFilter = true)
|
||||||
|
|
||||||
private fun calculateAverageChapterDurationMs(
|
|
||||||
chapterDurations: List<Long>,
|
|
||||||
): Long? {
|
|
||||||
val sampledDurations = chapterDurations
|
|
||||||
.filter { it in MIN_TRACKED_CHAPTER_DURATION_MS..MAX_TRACKED_CHAPTER_DURATION_MS }
|
|
||||||
.map { it.coerceAtMost(ReadingEtaDefaults.MAX_CHAPTER_DURATION_MS_FOR_STATS) }
|
|
||||||
.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)
|
|
||||||
} else {
|
} 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(
|
private fun compareEstimatedTimeLeft(
|
||||||
|
|
@ -542,15 +551,6 @@ class LibraryScreenModel(
|
||||||
val filterLewd = preferences.filterLewd
|
val filterLewd = preferences.filterLewd
|
||||||
// SY <--
|
// 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 = {
|
val filterFnDownloaded: suspend (LibraryItem) -> Boolean = {
|
||||||
applyFilter(filterDownloaded) {
|
applyFilter(filterDownloaded) {
|
||||||
it.libraryManga.manga.isLocal() ||
|
it.libraryManga.manga.isLocal() ||
|
||||||
|
|
@ -602,15 +602,12 @@ class LibraryScreenModel(
|
||||||
if (isNotLoggedInAnyTrack || trackFiltersIsIgnored) return@tracking true
|
if (isNotLoggedInAnyTrack || trackFiltersIsIgnored) return@tracking true
|
||||||
|
|
||||||
// KMK -->
|
// KMK -->
|
||||||
val mangaTracks = mangaTrackIds[item.id].orEmpty()
|
val mangaTracks = trackMap[item.id].orEmpty()
|
||||||
|
|
||||||
// Early return
|
val isExcluded = excludedTracks.isNotEmpty() && mangaTracks.fastAny { it.trackerId in excludedTracks }
|
||||||
if (mangaTracks.isEmpty() && includedTracks.isNotEmpty()) return@tracking false
|
val isIncluded = includedTracks.isEmpty() || mangaTracks.fastAny { it.trackerId in includedTracks }
|
||||||
// KMK <--
|
// KMK <--
|
||||||
|
|
||||||
val isExcluded = excludedTracks.isNotEmpty() && mangaTracks.fastAny { it in excludedTracks }
|
|
||||||
val isIncluded = includedTracks.isEmpty() || mangaTracks.fastAny { it in includedTracks }
|
|
||||||
|
|
||||||
!isExcluded && isIncluded
|
!isExcluded && isIncluded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -898,7 +895,8 @@ class LibraryScreenModel(
|
||||||
getLibraryManga.subscribe(),
|
getLibraryManga.subscribe(),
|
||||||
getLibraryItemPreferencesFlow(),
|
getLibraryItemPreferencesFlow(),
|
||||||
downloadCache.changes,
|
downloadCache.changes,
|
||||||
) { libraryManga, preferences, _ ->
|
getHistoryRevision.subscribe(),
|
||||||
|
) { libraryManga, preferences, _, _ ->
|
||||||
libraryManga.map { manga ->
|
libraryManga.map { manga ->
|
||||||
// Display mode based on user preference: take it from global library setting or category
|
// Display mode based on user preference: take it from global library setting or category
|
||||||
// KMK -->
|
// KMK -->
|
||||||
|
|
@ -1011,15 +1009,19 @@ class LibraryScreenModel(
|
||||||
* Queues the amount specified of unread chapters from the list of selected manga
|
* Queues the amount specified of unread chapters from the list of selected manga
|
||||||
*/
|
*/
|
||||||
fun performDownloadAction(action: DownloadAction) {
|
fun performDownloadAction(action: DownloadAction) {
|
||||||
val mangas = state.value.selectedManga
|
when (action) {
|
||||||
val amount = when (action) {
|
DownloadAction.NEXT_1_CHAPTER -> downloadNextChapters(1)
|
||||||
DownloadAction.NEXT_1_CHAPTER -> 1
|
DownloadAction.NEXT_5_CHAPTERS -> downloadNextChapters(5)
|
||||||
DownloadAction.NEXT_5_CHAPTERS -> 5
|
DownloadAction.NEXT_10_CHAPTERS -> downloadNextChapters(10)
|
||||||
DownloadAction.NEXT_10_CHAPTERS -> 10
|
DownloadAction.NEXT_25_CHAPTERS -> downloadNextChapters(25)
|
||||||
DownloadAction.NEXT_25_CHAPTERS -> 25
|
DownloadAction.UNREAD_CHAPTERS -> downloadNextChapters(null)
|
||||||
DownloadAction.UNREAD_CHAPTERS -> null
|
DownloadAction.BOOKMARKED_CHAPTERS -> downloadBookmarkedChapters()
|
||||||
}
|
}
|
||||||
clearSelection()
|
clearSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun downloadNextChapters(amount: Int?) {
|
||||||
|
val mangas = state.value.selectedManga
|
||||||
screenModelScope.launchNonCancellable {
|
screenModelScope.launchNonCancellable {
|
||||||
mangas.forEach { manga ->
|
mangas.forEach { manga ->
|
||||||
// SY -->
|
// SY -->
|
||||||
|
|
@ -1069,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 -->
|
// SY -->
|
||||||
fun cleanTitles() {
|
fun cleanTitles() {
|
||||||
val regex1 = "\\[.*?]".toRegex()
|
val regex1 = "\\[.*?]".toRegex()
|
||||||
|
|
@ -1878,10 +1928,6 @@ class LibraryScreenModel(
|
||||||
|
|
||||||
// KMK -->
|
// KMK -->
|
||||||
companion object {
|
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 */
|
/** List of MangaDex UUIDs subject to DMCA takedowns */
|
||||||
@Volatile
|
@Volatile
|
||||||
private var mangaDexDmcaUuids = hashSetOf<String>()
|
private var mangaDexDmcaUuids = hashSetOf<String>()
|
||||||
|
|
|
||||||
|
|
@ -329,8 +329,8 @@ data object LibraryTab : Tab {
|
||||||
}.takeIf { state.showMangaContinueButton },
|
}.takeIf { state.showMangaContinueButton },
|
||||||
onToggleSelection = screenModel::toggleSelection,
|
onToggleSelection = screenModel::toggleSelection,
|
||||||
onToggleRangeSelection = { category, manga ->
|
onToggleRangeSelection = { category, manga ->
|
||||||
screenModel.toggleRangeSelection(category, manga)
|
|
||||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
|
screenModel.toggleRangeSelection(category, manga)
|
||||||
},
|
},
|
||||||
onRefresh = { onClickRefresh(state.activeCategory) },
|
onRefresh = { onClickRefresh(state.activeCategory) },
|
||||||
onGlobalSearchClicked = {
|
onGlobalSearchClicked = {
|
||||||
|
|
|
||||||
|
|
@ -62,22 +62,20 @@ class LibraryUpdateErrorScreenModel(
|
||||||
fun toggleSelection(
|
fun toggleSelection(
|
||||||
item: LibraryUpdateErrorItem,
|
item: LibraryUpdateErrorItem,
|
||||||
selected: Boolean,
|
selected: Boolean,
|
||||||
userSelected: Boolean = false,
|
|
||||||
fromLongPress: Boolean = false,
|
fromLongPress: Boolean = false,
|
||||||
) {
|
) {
|
||||||
mutableState.update { state ->
|
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 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 }
|
val firstSelection = none { it.selected }
|
||||||
set(selectedIndex, selectedItem.copy(selected = selected))
|
set(selectedIndex, selectedItem.copy(selected = selected))
|
||||||
selectedErrorIds.addOrRemove(item.error.errorId, selected)
|
selectedErrorIds.addOrRemove(item.error.errorId, selected)
|
||||||
|
|
||||||
if (selected && userSelected && fromLongPress) {
|
if (selected && fromLongPress) {
|
||||||
if (firstSelection) {
|
if (firstSelection) {
|
||||||
selectedPositions[0] = selectedIndex
|
selectedPositions[0] = selectedIndex
|
||||||
selectedPositions[1] = selectedIndex
|
selectedPositions[1] = selectedIndex
|
||||||
|
|
@ -96,14 +94,14 @@ class LibraryUpdateErrorScreenModel(
|
||||||
}
|
}
|
||||||
|
|
||||||
range.forEach {
|
range.forEach {
|
||||||
val inbetweenItem = get(it)
|
val inBetweenItem = get(it)
|
||||||
if (!inbetweenItem.selected) {
|
if (!inBetweenItem.selected) {
|
||||||
selectedErrorIds.add(inbetweenItem.error.errorId)
|
selectedErrorIds.add(inBetweenItem.error.errorId)
|
||||||
set(it, inbetweenItem.copy(selected = true))
|
set(it, inBetweenItem.copy(selected = true))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (userSelected && !fromLongPress) {
|
} else if (!fromLongPress) {
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
if (selectedIndex == selectedPositions[0]) {
|
if (selectedIndex == selectedPositions[0]) {
|
||||||
selectedPositions[0] = indexOfFirst { it.selected }
|
selectedPositions[0] = indexOfFirst { it.selected }
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ import eu.kanade.presentation.components.UpdatingBannerBackgroundColor
|
||||||
import eu.kanade.presentation.more.settings.screen.ConfigureExhDialog
|
import eu.kanade.presentation.more.settings.screen.ConfigureExhDialog
|
||||||
import eu.kanade.presentation.more.settings.screen.about.AboutScreen.Companion.getReleaseNotes
|
import eu.kanade.presentation.more.settings.screen.about.AboutScreen.Companion.getReleaseNotes
|
||||||
import eu.kanade.presentation.more.settings.screen.about.WhatsNewDialog
|
import eu.kanade.presentation.more.settings.screen.about.WhatsNewDialog
|
||||||
import eu.kanade.presentation.more.settings.screen.browse.ExtensionReposScreen
|
import eu.kanade.presentation.more.settings.screen.browse.ExtensionStoresScreen
|
||||||
import eu.kanade.presentation.more.settings.screen.data.RestoreBackupScreen
|
import eu.kanade.presentation.more.settings.screen.data.RestoreBackupScreen
|
||||||
import eu.kanade.presentation.util.AssistContentScreen
|
import eu.kanade.presentation.util.AssistContentScreen
|
||||||
import eu.kanade.presentation.util.DefaultNavigatorScreenTransition
|
import eu.kanade.presentation.util.DefaultNavigatorScreenTransition
|
||||||
|
|
@ -693,11 +693,11 @@ class MainActivity : BaseActivity() {
|
||||||
navigator.popUntilRoot()
|
navigator.popUntilRoot()
|
||||||
navigator.push(RestoreBackupScreen(intent.data.toString()))
|
navigator.push(RestoreBackupScreen(intent.data.toString()))
|
||||||
}
|
}
|
||||||
// Deep link to add extension repo
|
// Deep link to add extension store
|
||||||
else if (intent.scheme == "tachiyomi" && intent.data?.host == "add-repo") {
|
else if (intent.isAddExtensionStoreIntent()) {
|
||||||
intent.data?.getQueryParameter("url")?.let { repoUrl ->
|
intent.data?.getQueryParameter("url")?.let { repoUrl ->
|
||||||
navigator.popUntilRoot()
|
navigator.popUntilRoot()
|
||||||
navigator.push(ExtensionReposScreen(repoUrl))
|
navigator.push(ExtensionStoresScreen(repoUrl))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
null
|
null
|
||||||
|
|
@ -713,6 +713,11 @@ class MainActivity : BaseActivity() {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun Intent.isAddExtensionStoreIntent(): Boolean {
|
||||||
|
return (scheme == "tachiyomi" && data?.host == "add-repo") ||
|
||||||
|
(scheme == "mihon" && data?.host == "extension-store")
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val INTENT_SEARCH = "eu.kanade.tachiyomi.SEARCH"
|
const val INTENT_SEARCH = "eu.kanade.tachiyomi.SEARCH"
|
||||||
const val INTENT_SEARCH_QUERY = "query"
|
const val INTENT_SEARCH_QUERY = "query"
|
||||||
|
|
|
||||||
|
|
@ -278,8 +278,8 @@ class MangaScreen(
|
||||||
onChapterClicked = { openChapter(context, it) },
|
onChapterClicked = { openChapter(context, it) },
|
||||||
onDownloadChapter = screenModel::runChapterDownloadActions.takeIf { !successState.source.isLocalOrStub() },
|
onDownloadChapter = screenModel::runChapterDownloadActions.takeIf { !successState.source.isLocalOrStub() },
|
||||||
onAddToLibraryClicked = {
|
onAddToLibraryClicked = {
|
||||||
screenModel.toggleFavorite()
|
|
||||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
|
screenModel.toggleFavorite()
|
||||||
},
|
},
|
||||||
// SY -->
|
// SY -->
|
||||||
onWebViewClicked = {
|
onWebViewClicked = {
|
||||||
|
|
|
||||||
|
|
@ -555,8 +555,8 @@ class MangaScreenModel(
|
||||||
}
|
}
|
||||||
val vibrantColor = it.getBestColor() ?: return@launchIO
|
val vibrantColor = it.getBestColor() ?: return@launchIO
|
||||||
mangaCover.vibrantCoverColor = vibrantColor
|
mangaCover.vibrantCoverColor = vibrantColor
|
||||||
updateSuccessState {
|
updateSuccessState { state ->
|
||||||
it.copy(seedColor = Color(vibrantColor))
|
state.copy(seedColor = Color(vibrantColor))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1322,6 +1322,13 @@ class MangaScreenModel(
|
||||||
return if (manga.sortDescending()) chaptersSorted.reversed() else chaptersSorted
|
return if (manga.sortDescending()) chaptersSorted.reversed() else chaptersSorted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getBookmarkedChapters(): List<Chapter> {
|
||||||
|
val chapterItems = if (skipFiltered) filteredChapters.orEmpty() else allChapters.orEmpty()
|
||||||
|
return chapterItems
|
||||||
|
.filter { (chapter, dlStatus) -> chapter.bookmark && dlStatus == Download.State.NOT_DOWNLOADED }
|
||||||
|
.map { it.chapter }
|
||||||
|
}
|
||||||
|
|
||||||
private fun startDownload(
|
private fun startDownload(
|
||||||
chapters: List<Chapter>,
|
chapters: List<Chapter>,
|
||||||
startNow: Boolean,
|
startNow: Boolean,
|
||||||
|
|
@ -1384,6 +1391,7 @@ class MangaScreenModel(
|
||||||
DownloadAction.NEXT_10_CHAPTERS -> getUnreadChaptersSorted().take(10)
|
DownloadAction.NEXT_10_CHAPTERS -> getUnreadChaptersSorted().take(10)
|
||||||
DownloadAction.NEXT_25_CHAPTERS -> getUnreadChaptersSorted().take(25)
|
DownloadAction.NEXT_25_CHAPTERS -> getUnreadChaptersSorted().take(25)
|
||||||
DownloadAction.UNREAD_CHAPTERS -> getUnreadChapters()
|
DownloadAction.UNREAD_CHAPTERS -> getUnreadChapters()
|
||||||
|
DownloadAction.BOOKMARKED_CHAPTERS -> getBookmarkedChapters()
|
||||||
}
|
}
|
||||||
if (chaptersToDownload.isNotEmpty()) {
|
if (chaptersToDownload.isNotEmpty()) {
|
||||||
startDownload(chaptersToDownload, false)
|
startDownload(chaptersToDownload, false)
|
||||||
|
|
@ -1725,22 +1733,22 @@ class MangaScreenModel(
|
||||||
fun toggleSelection(
|
fun toggleSelection(
|
||||||
item: ChapterList.Item,
|
item: ChapterList.Item,
|
||||||
selected: Boolean,
|
selected: Boolean,
|
||||||
userSelected: Boolean = false,
|
|
||||||
fromLongPress: Boolean = false,
|
fromLongPress: Boolean = false,
|
||||||
) {
|
) {
|
||||||
updateSuccessState { successState ->
|
updateSuccessState { successState ->
|
||||||
|
// KMK -->
|
||||||
|
val selectedIndex = successState.processedChapters.indexOfFirst { it.id == item.chapter.id }
|
||||||
|
if (selectedIndex < 0) return@updateSuccessState successState
|
||||||
|
val selectedItem = successState.processedChapters[selectedIndex]
|
||||||
|
if (selectedItem.selected == selected) return@updateSuccessState successState
|
||||||
|
// KMK <--
|
||||||
|
|
||||||
val newChapters = successState.processedChapters.toMutableList().apply {
|
val newChapters = successState.processedChapters.toMutableList().apply {
|
||||||
val selectedIndex = successState.processedChapters.indexOfFirst { it.id == item.chapter.id }
|
|
||||||
if (selectedIndex < 0) return@apply
|
|
||||||
|
|
||||||
val selectedItem = get(selectedIndex)
|
|
||||||
if ((selectedItem.selected && selected) || (!selectedItem.selected && !selected)) return@apply
|
|
||||||
|
|
||||||
val firstSelection = none { it.selected }
|
val firstSelection = none { it.selected }
|
||||||
set(selectedIndex, selectedItem.copy(selected = selected))
|
set(selectedIndex, selectedItem.copy(selected = selected))
|
||||||
selectedChapterIds.addOrRemove(item.id, selected)
|
selectedChapterIds.addOrRemove(item.id, selected)
|
||||||
|
|
||||||
if (selected && userSelected && fromLongPress) {
|
if (selected && fromLongPress) {
|
||||||
if (firstSelection) {
|
if (firstSelection) {
|
||||||
selectedPositions[0] = selectedIndex
|
selectedPositions[0] = selectedIndex
|
||||||
selectedPositions[1] = selectedIndex
|
selectedPositions[1] = selectedIndex
|
||||||
|
|
@ -1759,14 +1767,14 @@ class MangaScreenModel(
|
||||||
}
|
}
|
||||||
|
|
||||||
range.forEach {
|
range.forEach {
|
||||||
val inbetweenItem = get(it)
|
val inBetweenItem = get(it)
|
||||||
if (!inbetweenItem.selected) {
|
if (!inBetweenItem.selected) {
|
||||||
selectedChapterIds.add(inbetweenItem.id)
|
selectedChapterIds.add(inBetweenItem.id)
|
||||||
set(it, inbetweenItem.copy(selected = true))
|
set(it, inBetweenItem.copy(selected = true))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (userSelected && !fromLongPress) {
|
} else if (!fromLongPress) {
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
if (selectedIndex == selectedPositions[0]) {
|
if (selectedIndex == selectedPositions[0]) {
|
||||||
selectedPositions[0] = indexOfFirst { it.selected }
|
selectedPositions[0] = indexOfFirst { it.selected }
|
||||||
|
|
|
||||||
|
|
@ -99,8 +99,10 @@ import tachiyomi.domain.history.interactor.UpsertHistory
|
||||||
import tachiyomi.domain.history.model.HistoryUpdate
|
import tachiyomi.domain.history.model.HistoryUpdate
|
||||||
import tachiyomi.domain.library.service.LibraryPreferences
|
import tachiyomi.domain.library.service.LibraryPreferences
|
||||||
import tachiyomi.domain.reading.ReadingEtaHistorySample
|
import tachiyomi.domain.reading.ReadingEtaHistorySample
|
||||||
import tachiyomi.domain.reading.ReadingEtaPageRateEstimator
|
|
||||||
import tachiyomi.domain.reading.ReadingEtaDefaults
|
import tachiyomi.domain.reading.ReadingEtaDefaults
|
||||||
|
import tachiyomi.domain.reading.ReadingEtaLimits
|
||||||
|
import tachiyomi.domain.reading.SeriesReadingEtaCalculator
|
||||||
|
import tachiyomi.domain.reading.service.ReadingEtaPreferences
|
||||||
import tachiyomi.domain.manga.interactor.GetFlatMetadataById
|
import tachiyomi.domain.manga.interactor.GetFlatMetadataById
|
||||||
import tachiyomi.domain.manga.interactor.GetManga
|
import tachiyomi.domain.manga.interactor.GetManga
|
||||||
import tachiyomi.domain.manga.interactor.GetMergedMangaById
|
import tachiyomi.domain.manga.interactor.GetMergedMangaById
|
||||||
|
|
@ -137,6 +139,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||||
private val setMangaViewerFlags: SetMangaViewerFlags = Injekt.get(),
|
private val setMangaViewerFlags: SetMangaViewerFlags = Injekt.get(),
|
||||||
private val getIncognitoState: GetIncognitoState = Injekt.get(),
|
private val getIncognitoState: GetIncognitoState = Injekt.get(),
|
||||||
private val libraryPreferences: LibraryPreferences = Injekt.get(),
|
private val libraryPreferences: LibraryPreferences = Injekt.get(),
|
||||||
|
private val readingEtaPreferences: ReadingEtaPreferences = Injekt.get(),
|
||||||
// SY -->
|
// SY -->
|
||||||
private val syncPreferences: SyncPreferences = Injekt.get(),
|
private val syncPreferences: SyncPreferences = Injekt.get(),
|
||||||
private val uiPreferences: UiPreferences = Injekt.get(),
|
private val uiPreferences: UiPreferences = Injekt.get(),
|
||||||
|
|
@ -266,7 +269,6 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||||
private var crossMangaPageRateSamples: List<ReadingEtaHistorySample> = emptyList()
|
private var crossMangaPageRateSamples: List<ReadingEtaHistorySample> = emptyList()
|
||||||
|
|
||||||
private var orderedSeriesChapterIds = emptyList<Long>()
|
private var orderedSeriesChapterIds = emptyList<Long>()
|
||||||
private var seriesChapterIndexById = emptyMap<Long, Int>()
|
|
||||||
|
|
||||||
private var chapterToDownload: Download? = null
|
private var chapterToDownload: Download? = null
|
||||||
|
|
||||||
|
|
@ -935,16 +937,17 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||||
getCurrentChapter()?.let { readerChapter ->
|
getCurrentChapter()?.let { readerChapter ->
|
||||||
if (incognitoMode) return@let
|
if (incognitoMode) return@let
|
||||||
|
|
||||||
|
val limits = readingEtaPreferences.currentLimits()
|
||||||
val chapterId = readerChapter.chapter.id!!
|
val chapterId = readerChapter.chapter.id!!
|
||||||
val endTime = Date()
|
val endTime = Date()
|
||||||
val sessionReadDuration = chapterReadStartTime
|
val sessionReadDuration = chapterReadStartTime
|
||||||
?.let { SystemClock.elapsedRealtime() - it }
|
?.let { SystemClock.elapsedRealtime() - it }
|
||||||
?.sanitizeTrackedDuration()
|
?.sanitizeTrackedDuration(limits)
|
||||||
?: 0
|
?: 0
|
||||||
val sessionReadPages = chapterReadStartPageIndex
|
val sessionReadPages = chapterReadStartPageIndex
|
||||||
?.let { startPageIndex ->
|
?.let { startPageIndex ->
|
||||||
val endPageIndex = readerChapter.chapter.last_page_read
|
val endPageIndex = readerChapter.chapter.last_page_read
|
||||||
(endPageIndex - startPageIndex + 1L).sanitizeTrackedPages()
|
(endPageIndex - startPageIndex + 1L).sanitizeTrackedPages(limits)
|
||||||
}
|
}
|
||||||
?: 0L
|
?: 0L
|
||||||
|
|
||||||
|
|
@ -968,9 +971,6 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||||
orderedSeriesChapterIds = unfilteredChapterList
|
orderedSeriesChapterIds = unfilteredChapterList
|
||||||
.sortedWith(getChapterSort(manga, sortDescending = false))
|
.sortedWith(getChapterSort(manga, sortDescending = false))
|
||||||
.mapNotNull { it.id }
|
.mapNotNull { it.id }
|
||||||
seriesChapterIndexById = orderedSeriesChapterIds
|
|
||||||
.withIndex()
|
|
||||||
.associate { (index, chapterId) -> chapterId to index }
|
|
||||||
|
|
||||||
chapterReadDurationsSession.clear()
|
chapterReadDurationsSession.clear()
|
||||||
chapterReadDurationsPersistent.clear()
|
chapterReadDurationsPersistent.clear()
|
||||||
|
|
@ -996,90 +996,49 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val currentChapterIndex = seriesChapterIndexById[currentChapterId]
|
val limits = readingEtaPreferences.currentLimits()
|
||||||
if (currentChapterIndex == null) {
|
val chapterDurations = getEtaChapterDurations(currentChapterId, limits)
|
||||||
|
val chapterPages = getEtaChapterPages(currentChapterId, limits)
|
||||||
|
val activeSession = chapterReadStartTime?.let { startTime ->
|
||||||
|
chapterReadStartPageIndex?.let { startPageIndex ->
|
||||||
|
val totalPages = state.value.totalPages
|
||||||
|
if (totalPages <= 1) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
SeriesReadingEtaCalculator.ActiveSession(
|
||||||
|
sessionDurationMs = SystemClock.elapsedRealtime() - startTime,
|
||||||
|
startPageIndex = startPageIndex,
|
||||||
|
currentPageIndex = (state.value.currentPage - 1).coerceAtLeast(startPageIndex),
|
||||||
|
totalPages = totalPages,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val result = SeriesReadingEtaCalculator.estimate(
|
||||||
|
SeriesReadingEtaCalculator.Input(
|
||||||
|
orderedChapterIds = orderedSeriesChapterIds,
|
||||||
|
chapterReadById = unfilteredChapterList.associate { it.id to it.read },
|
||||||
|
currentChapterId = currentChapterId,
|
||||||
|
currentPage = state.value.currentPage,
|
||||||
|
totalPages = state.value.totalPages,
|
||||||
|
chapterDurations = chapterDurations,
|
||||||
|
chapterPages = chapterPages,
|
||||||
|
crossMangaPageRateSamples = crossMangaPageRateSamples,
|
||||||
|
limits = limits,
|
||||||
|
activeSession = activeSession,
|
||||||
|
),
|
||||||
|
) ?: run {
|
||||||
mutableState.update { it.copy(seriesReadingProgress = null) }
|
mutableState.update { it.copy(seriesReadingProgress = null) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val totalChapters = orderedSeriesChapterIds.size
|
|
||||||
val isCurrentChapterRead = state.value.currentChapter?.chapter?.read == true
|
|
||||||
val remainingChapters = (
|
|
||||||
totalChapters - currentChapterIndex - if (isCurrentChapterRead) 1 else 0
|
|
||||||
).coerceAtLeast(0)
|
|
||||||
val chapterDurations = getEtaChapterDurations(currentChapterId)
|
|
||||||
val chapterPages = getEtaChapterPages(currentChapterId)
|
|
||||||
val currentChapterProgress = calculateChapterProgress(
|
|
||||||
currentPage = state.value.currentPage,
|
|
||||||
totalPages = state.value.totalPages,
|
|
||||||
)
|
|
||||||
val projectedCurrentChapterDuration =
|
|
||||||
estimateFullChapterMsFromActiveSession()
|
|
||||||
?.coerceAtMost(ReadingEtaDefaults.MAX_CHAPTER_DURATION_MS_FOR_STATS)
|
|
||||||
?: calculateProjectedChapterDurationMs(
|
|
||||||
chapterDuration = chapterDurations[currentChapterId] ?: 0L,
|
|
||||||
chapterProgress = currentChapterProgress,
|
|
||||||
)
|
|
||||||
val averageChapterDuration = calculateAverageChapterDurationMs(
|
|
||||||
chapterDurations = chapterDurations,
|
|
||||||
currentChapterId = currentChapterId,
|
|
||||||
currentChapterIndex = currentChapterIndex,
|
|
||||||
isCurrentChapterRead = isCurrentChapterRead,
|
|
||||||
currentChapterProgress = currentChapterProgress,
|
|
||||||
)
|
|
||||||
val pageBasedEta = calculatePageBasedEtaMillis(
|
|
||||||
chapterDurations = chapterDurations,
|
|
||||||
chapterPages = chapterPages,
|
|
||||||
currentChapterId = currentChapterId,
|
|
||||||
currentChapterIndex = currentChapterIndex,
|
|
||||||
isCurrentChapterRead = isCurrentChapterRead,
|
|
||||||
currentChapterProgress = currentChapterProgress,
|
|
||||||
totalPages = state.value.totalPages,
|
|
||||||
currentPage = state.value.currentPage,
|
|
||||||
remainingChapters = remainingChapters,
|
|
||||||
)
|
|
||||||
val chapterDurationForWholeChapters = averageChapterDuration ?: projectedCurrentChapterDuration
|
|
||||||
val chapterDurationForCurrentChapter = projectedCurrentChapterDuration ?: chapterDurationForWholeChapters
|
|
||||||
val chapterBasedEta = when {
|
|
||||||
totalChapters == 1 -> {
|
|
||||||
if (isCurrentChapterRead) {
|
|
||||||
0L
|
|
||||||
} else {
|
|
||||||
calculateRemainingChapterTimeMs(
|
|
||||||
chapterDuration = chapterDurationForCurrentChapter,
|
|
||||||
chapterProgress = currentChapterProgress,
|
|
||||||
) ?: chapterDurationForCurrentChapter
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
when {
|
|
||||||
remainingChapters == 0 -> 0L
|
|
||||||
else -> chapterDurationForWholeChapters?.let { chapterDurationMs ->
|
|
||||||
val currentChapterRemainingMs = if (isCurrentChapterRead) {
|
|
||||||
0L
|
|
||||||
} else {
|
|
||||||
calculateRemainingChapterTimeMs(
|
|
||||||
chapterDuration = chapterDurationForCurrentChapter,
|
|
||||||
chapterProgress = currentChapterProgress,
|
|
||||||
) ?: chapterDurationForCurrentChapter ?: 0L
|
|
||||||
}
|
|
||||||
val remainingWholeChapters = (
|
|
||||||
remainingChapters - if (isCurrentChapterRead) 0 else 1
|
|
||||||
).coerceAtLeast(0)
|
|
||||||
currentChapterRemainingMs + (chapterDurationMs * remainingWholeChapters)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val etaMillis = (pageBasedEta ?: chapterBasedEta)?.coerceAtLeast(0L)
|
|
||||||
|
|
||||||
mutableState.update {
|
mutableState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
seriesReadingProgress = SeriesReadingProgress(
|
seriesReadingProgress = SeriesReadingProgress(
|
||||||
currentChapter = currentChapterIndex + 1,
|
currentChapter = result.currentChapter,
|
||||||
totalChapters = totalChapters,
|
totalChapters = result.totalChapters,
|
||||||
remainingChapters = remainingChapters,
|
remainingChapters = result.remainingChapters,
|
||||||
etaMillis = etaMillis,
|
etaMillis = result.etaMillis,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1089,7 +1048,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getEtaChapterDurations(currentChapterId: Long): Map<Long, Long> {
|
private fun getEtaChapterDurations(currentChapterId: Long, limits: ReadingEtaLimits): Map<Long, Long> {
|
||||||
val trackedDurations = if (readerPreferences.persistentSeriesEta().get()) {
|
val trackedDurations = if (readerPreferences.persistentSeriesEta().get()) {
|
||||||
chapterReadDurationsPersistent.toMutableMap()
|
chapterReadDurationsPersistent.toMutableMap()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1098,7 +1057,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||||
|
|
||||||
val inProgressDuration = chapterReadStartTime
|
val inProgressDuration = chapterReadStartTime
|
||||||
?.let { SystemClock.elapsedRealtime() - it }
|
?.let { SystemClock.elapsedRealtime() - it }
|
||||||
?.sanitizeTrackedDuration()
|
?.sanitizeTrackedDuration(limits)
|
||||||
?: 0L
|
?: 0L
|
||||||
if (inProgressDuration > 0L) {
|
if (inProgressDuration > 0L) {
|
||||||
trackedDurations.merge(currentChapterId, inProgressDuration, Long::plus)
|
trackedDurations.merge(currentChapterId, inProgressDuration, Long::plus)
|
||||||
|
|
@ -1107,7 +1066,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||||
return trackedDurations
|
return trackedDurations
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getEtaChapterPages(currentChapterId: Long): Map<Long, Long> {
|
private fun getEtaChapterPages(currentChapterId: Long, limits: ReadingEtaLimits): Map<Long, Long> {
|
||||||
val trackedPages = if (readerPreferences.persistentSeriesEta().get()) {
|
val trackedPages = if (readerPreferences.persistentSeriesEta().get()) {
|
||||||
chapterReadPagesPersistent.toMutableMap()
|
chapterReadPagesPersistent.toMutableMap()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1116,7 +1075,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||||
val inProgressPages = chapterReadStartPageIndex
|
val inProgressPages = chapterReadStartPageIndex
|
||||||
?.let { startPageIndex ->
|
?.let { startPageIndex ->
|
||||||
val currentPageIndex = (state.value.currentPage - 1).coerceAtLeast(startPageIndex)
|
val currentPageIndex = (state.value.currentPage - 1).coerceAtLeast(startPageIndex)
|
||||||
(currentPageIndex - startPageIndex + 1L).sanitizeTrackedPages()
|
(currentPageIndex - startPageIndex + 1L).sanitizeTrackedPages(limits)
|
||||||
}
|
}
|
||||||
?: 0L
|
?: 0L
|
||||||
if (inProgressPages > 0L) {
|
if (inProgressPages > 0L) {
|
||||||
|
|
@ -1125,158 +1084,12 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||||
return trackedPages
|
return trackedPages
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun calculatePageBasedEtaMillis(
|
private fun Long.sanitizeTrackedDuration(limits: ReadingEtaLimits): Long {
|
||||||
chapterDurations: Map<Long, Long>,
|
return if (this in limits.minTrackedChapterDurationMs..limits.maxTrackedChapterDurationMs) this else 0L
|
||||||
chapterPages: Map<Long, Long>,
|
|
||||||
currentChapterId: Long,
|
|
||||||
currentChapterIndex: Int,
|
|
||||||
isCurrentChapterRead: Boolean,
|
|
||||||
currentChapterProgress: Double?,
|
|
||||||
totalPages: Int,
|
|
||||||
currentPage: Int,
|
|
||||||
remainingChapters: Int,
|
|
||||||
): Long? {
|
|
||||||
val seriesPageRatesMsPerPage = orderedSeriesChapterIds
|
|
||||||
.take(currentChapterIndex + 1)
|
|
||||||
.mapNotNull { chapterId ->
|
|
||||||
val duration = chapterDurations[chapterId]?.sanitizeTrackedDuration() ?: return@mapNotNull null
|
|
||||||
val pages = chapterPages[chapterId]?.sanitizeTrackedPages() ?: return@mapNotNull null
|
|
||||||
if (duration <= 0L || pages <= 0L) return@mapNotNull null
|
|
||||||
duration.toDouble() / pages.toDouble()
|
|
||||||
}
|
|
||||||
.takeLast(ETA_SAMPLE_CHAPTER_LIMIT)
|
|
||||||
|
|
||||||
val sampledPages = orderedSeriesChapterIds
|
|
||||||
.take(currentChapterIndex + 1)
|
|
||||||
.mapNotNull { chapterPages[it]?.sanitizeTrackedPages() }
|
|
||||||
.takeLast(ETA_SAMPLE_CHAPTER_LIMIT)
|
|
||||||
if (sampledPages.isEmpty()) return null
|
|
||||||
val avgPagesPerChapter = (sampledPages.sum() / sampledPages.size).coerceAtLeast(1L)
|
|
||||||
|
|
||||||
if (seriesPageRatesMsPerPage.isEmpty() && crossMangaPageRateSamples.isEmpty()) return null
|
|
||||||
|
|
||||||
val targetChapterPages = if (totalPages > 0) {
|
|
||||||
totalPages
|
|
||||||
} else {
|
|
||||||
avgPagesPerChapter.toInt().coerceAtLeast(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
val avgMsPerPage = ReadingEtaPageRateEstimator.estimateBlendedMsPerPage(
|
|
||||||
targetChapterPages = targetChapterPages,
|
|
||||||
seriesPageRatesMsPerPage = seriesPageRatesMsPerPage,
|
|
||||||
crossMangaSamples = crossMangaPageRateSamples,
|
|
||||||
) ?: return null
|
|
||||||
|
|
||||||
val currentRemainingPages = if (isCurrentChapterRead) {
|
|
||||||
0L
|
|
||||||
} else if (totalPages > 0 && currentPage > 0) {
|
|
||||||
(totalPages - currentPage).toLong().coerceAtLeast(0L)
|
|
||||||
} else {
|
|
||||||
val progress = (currentChapterProgress ?: 0.0).coerceIn(0.0, 1.0)
|
|
||||||
((1.0 - progress) * avgPagesPerChapter.toDouble()).toLong().coerceAtLeast(0L)
|
|
||||||
}
|
|
||||||
val remainingWholeChapters = (remainingChapters - if (isCurrentChapterRead) 0 else 1).coerceAtLeast(0)
|
|
||||||
return currentRemainingPages * avgMsPerPage + remainingWholeChapters * avgPagesPerChapter * avgMsPerPage
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun calculateAverageChapterDurationMs(
|
private fun Long.sanitizeTrackedPages(limits: ReadingEtaLimits): Long {
|
||||||
chapterDurations: Map<Long, Long>,
|
return if (this in ReadingEtaDefaults.MIN_PAGES_READ_FOR_PAGE_RATE..limits.maxPagesReadForPageRate) this else 0L
|
||||||
currentChapterId: Long,
|
|
||||||
currentChapterIndex: Int,
|
|
||||||
isCurrentChapterRead: Boolean,
|
|
||||||
currentChapterProgress: Double?,
|
|
||||||
): Long? {
|
|
||||||
if (currentChapterIndex < 0) return null
|
|
||||||
val sampledDurations = orderedSeriesChapterIds
|
|
||||||
.take(currentChapterIndex + 1)
|
|
||||||
.mapNotNull { chapterId ->
|
|
||||||
val chapterDuration = chapterDurations[chapterId] ?: return@mapNotNull null
|
|
||||||
val normalizedDuration = when {
|
|
||||||
chapterId == currentChapterId && !isCurrentChapterRead -> {
|
|
||||||
calculateProjectedChapterDurationMs(
|
|
||||||
chapterDuration = chapterDuration,
|
|
||||||
chapterProgress = currentChapterProgress,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
else -> chapterDuration
|
|
||||||
} ?: return@mapNotNull null
|
|
||||||
normalizedDuration
|
|
||||||
}
|
|
||||||
.filter { it in MIN_TRACKED_CHAPTER_DURATION_MS..MAX_TRACKED_CHAPTER_DURATION_MS }
|
|
||||||
.map { it.coerceAtMost(ReadingEtaDefaults.MAX_CHAPTER_DURATION_MS_FOR_STATS) }
|
|
||||||
.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)
|
|
||||||
} else {
|
|
||||||
sortedDurations
|
|
||||||
}
|
|
||||||
return trimmedDurations.sum() / trimmedDurations.size
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun calculateChapterProgress(
|
|
||||||
currentPage: Int,
|
|
||||||
totalPages: Int,
|
|
||||||
): Double? {
|
|
||||||
if (currentPage <= 0 || totalPages <= 1) return null
|
|
||||||
return (currentPage.toDouble() / totalPages.toDouble()).coerceIn(0.0, 1.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun calculateProjectedChapterDurationMs(
|
|
||||||
chapterDuration: Long,
|
|
||||||
chapterProgress: Double?,
|
|
||||||
): Long? {
|
|
||||||
if (chapterDuration <= 0L) return null
|
|
||||||
val progress = chapterProgress
|
|
||||||
?.takeIf { it >= MIN_PROGRESS_FOR_DURATION_PROJECTION }
|
|
||||||
?.coerceIn(0.0, 1.0)
|
|
||||||
?: return null
|
|
||||||
if (progress == 0.0) return null
|
|
||||||
return (chapterDuration.toDouble() / progress).toLong()
|
|
||||||
.coerceAtLeast(0L)
|
|
||||||
.coerceAtMost(ReadingEtaDefaults.MAX_CHAPTER_DURATION_MS_FOR_STATS)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Estimates full-chapter duration from the current session's ms/page rate.
|
|
||||||
* Prefer over scaling cumulative DB read time by absolute chapter progress — history times are
|
|
||||||
* summed across revisits, which inflates `duration / progress` for rereads.
|
|
||||||
*/
|
|
||||||
private fun estimateFullChapterMsFromActiveSession(): Long? {
|
|
||||||
val totalPages = state.value.totalPages
|
|
||||||
val startPageIndex = chapterReadStartPageIndex ?: return null
|
|
||||||
val startTime = chapterReadStartTime ?: return null
|
|
||||||
if (totalPages <= 1) return null
|
|
||||||
|
|
||||||
val sessionMs = (SystemClock.elapsedRealtime() - startTime).sanitizeTrackedDuration()
|
|
||||||
if (sessionMs < MIN_TRACKED_CHAPTER_DURATION_MS) return null
|
|
||||||
|
|
||||||
val currentPageIndex = (state.value.currentPage - 1).coerceAtLeast(startPageIndex)
|
|
||||||
val pagesThisSession = (currentPageIndex - startPageIndex + 1).coerceAtLeast(1)
|
|
||||||
val msPerPage = sessionMs.toDouble() / pagesThisSession.toDouble()
|
|
||||||
val estimated = (msPerPage * totalPages.toDouble()).toLong()
|
|
||||||
return estimated.takeIf { it > 0L }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun calculateRemainingChapterTimeMs(
|
|
||||||
chapterDuration: Long?,
|
|
||||||
chapterProgress: Double?,
|
|
||||||
): Long? {
|
|
||||||
val duration = chapterDuration?.takeIf { it > 0L } ?: return null
|
|
||||||
val remainingProgress = 1.0 - (chapterProgress ?: 0.0).coerceIn(0.0, 1.0)
|
|
||||||
return (duration.toDouble() * remainingProgress).toLong().coerceAtLeast(0L)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Long.sanitizeTrackedDuration(): Long {
|
|
||||||
return if (this in MIN_TRACKED_CHAPTER_DURATION_MS..MAX_TRACKED_CHAPTER_DURATION_MS) this else 0L
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Long.sanitizeTrackedPages(): Long {
|
|
||||||
return if (this in MIN_TRACKED_PAGES..MAX_TRACKED_PAGES) this else 0L
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1829,15 +1642,6 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val MIN_TRACKED_CHAPTER_DURATION_MS = 2_000L
|
|
||||||
const val MAX_TRACKED_CHAPTER_DURATION_MS = 4 * 60 * 60 * 1_000L
|
|
||||||
const val ETA_SAMPLE_CHAPTER_LIMIT = 20
|
|
||||||
const val MIN_PROGRESS_FOR_DURATION_PROJECTION = 0.05
|
|
||||||
const val MIN_TRACKED_PAGES = 1L
|
|
||||||
const val MAX_TRACKED_PAGES = 2_000L
|
|
||||||
}
|
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
data class State(
|
data class State(
|
||||||
val manga: Manga? = null,
|
val manga: Manga? = null,
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue