Compare commits

..
1 Commits
Author SHA1 Message Date
AntsyLich fd95fb8103 Add fastlane metadata (#3564) 2026-07-11 20:04:06 +06:00
1056 changed files with 40594 additions and 67403 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ body:
required: true
- label: I have written a short but informative title.
required: true
- label: I have updated the app to version **[0.21.2](https://github.com/mihonapp/mihon/releases/latest)**.
- label: I have updated the app to version **[0.20.1](https://github.com/mihonapp/mihon/releases/latest)**.
required: true
- label: I will fill out all of the requested information in this form.
required: true
+2 -2
View File
@@ -52,7 +52,7 @@ body:
label: Mihon version
description: You can find your Mihon version in **More → About**.
placeholder: |
Example: "0.21.2"
Example: "0.20.1"
validations:
required: true
@@ -95,7 +95,7 @@ body:
required: true
- label: I have gone through the [FAQ](https://mihon.app/docs/faq/general) and [troubleshooting guide](https://mihon.app/docs/guides/troubleshooting/).
required: true
- label: I have updated the app to version **[0.21.2](https://github.com/mihonapp/mihon/releases/latest)**.
- label: I have updated the app to version **[0.20.1](https://github.com/mihonapp/mihon/releases/latest)**.
required: true
- label: I have filled out all of the requested information in this form, including specific version numbers.
required: true
+8 -10
View File
@@ -23,26 +23,24 @@ permissions:
jobs:
build:
name: Build & Test App
runs-on: 'ubuntu-26.04'
runs-on: 'ubuntu-24.04'
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Dependency Review
if: github.event_name == 'pull_request'
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
- name: Set up JDK
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0
with:
java-version-file: .github/.java-version
distribution: temurin
- name: Set up Gradle
uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with:
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Check code format
run: ./gradlew spotlessCheck
@@ -56,22 +54,22 @@ jobs:
- name: Upload test report
if: steps.unit_tests.outcome == 'failure'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: test-report-${{ github.sha }}
path: app/build/reports/tests/testDebugUnitTest
- name: Build app
run: ./gradlew assembleRelease -Pdist=ci
run: ./gradlew assembleRelease -Pinclude-telemetry -Penable-updater
- name: Upload APK
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: arm64-v8a-${{ github.sha }}
path: app/build/outputs/apk/release/app-arm64-v8a-release.apk
- name: Upload mapping
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: mapping-${{ github.sha }}
path: app/build/outputs/mapping/release
+18 -30
View File
@@ -15,7 +15,7 @@ jobs:
get_tag:
if: github.repository == 'mihonapp/mihon'
name: Extract tag name
runs-on: 'ubuntu-26.04'
runs-on: 'ubuntu-24.04'
outputs:
tag: ${{ steps.extract.outputs.tag }}
@@ -27,33 +27,26 @@ jobs:
build:
if: github.repository == 'mihonapp/mihon'
name: Build
runs-on: 'ubuntu-26.04'
runs-on: 'ubuntu-24.04'
needs: get_tag
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up JDK
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0
with:
java-version-file: .github/.java-version
distribution: temurin
- name: Set up Gradle
uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with:
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Decode signing keystore
run: echo "$SIGNING_KEY" | base64 -d > "${{ runner.temp }}/antsy.keystore"
env:
SIGNING_KEY: ${{ secrets.SIGNING_KEY }}
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Build
run: ./gradlew assembleRelease -Pdist=github
run: ./gradlew assembleRelease -Pinclude-telemetry -Penable-updater
env:
storeFile: ${{ runner.temp }}/antsy.keystore
storeFileBase64: ${{ secrets.SIGNING_KEY }}
storePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyAlias: ${{ secrets.ALIAS }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
@@ -69,7 +62,7 @@ jobs:
mv app/build/outputs/apk/release/app-x86_64-release.apk mihon-x86_64-${{ needs.get_tag.outputs.tag }}.apk
- name: Upload APK
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: mihon
path: |
@@ -82,33 +75,28 @@ jobs:
build_foss:
if: github.repository == 'mihonapp/mihon'
name: Build (FOSS)
runs-on: ubuntu-26.04
runs-on: ubuntu-24.04
needs: get_tag
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up JDK
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0
with:
java-version-file: .github/.java-version
distribution: temurin
- name: Set up Gradle
uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
cache-disabled: true
- name: Decode signing keystore
run: echo "$SIGNING_KEY" | base64 -d > "${{ runner.temp }}/antsy.keystore"
env:
SIGNING_KEY: ${{ secrets.SIGNING_KEY }}
- name: Build
run: ./gradlew assembleFoss -Pdist=foss
run: ./gradlew assembleFoss -Penable-updater
env:
storeFile: ${{ runner.temp }}/antsy.keystore
storeFileBase64: ${{ secrets.SIGNING_KEY }}
storePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyAlias: ${{ secrets.ALIAS }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
@@ -120,7 +108,7 @@ jobs:
mv app/build/outputs/apk/foss/app-universal-foss.apk mihon-${{ needs.get_tag.outputs.tag }}-foss.apk
- name: Upload APK
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: mihon-foss
path: mihon-${{ needs.get_tag.outputs.tag }}-foss.apk
@@ -128,12 +116,12 @@ jobs:
release:
if: github.repository == 'mihonapp/mihon'
name: Create GitHub Release
runs-on: ubuntu-26.04
runs-on: ubuntu-24.04
needs: [get_tag, build, build_foss]
steps:
- name: Download all artifacts
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
merge-multiple: true
@@ -146,7 +134,7 @@ jobs:
mihon-foss
- name: Create GitHub Release
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with:
tag_name: ${{ needs.get_tag.outputs.tag }}
name: Mihon ${{ needs.get_tag.outputs.tag }}
+1 -1
View File
@@ -9,7 +9,7 @@ on:
jobs:
update_website:
runs-on: 'ubuntu-26.04'
runs-on: 'ubuntu-24.04'
steps:
- name: Update website
+2 -241
View File
@@ -12,241 +12,8 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
## [Unreleased]
### Added
- Add experimental high quality reader renderer (Settings → Advanced → Use high quality renderer) with better image scaling, page transition animations, dual page view and display cutout handling ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3388](https://github.com/mihonapp/mihon/pull/3388))
- Match background and transition page colors to the old reader ([@AntsyLich](https://github.com/AntsyLich)) ([#3765](https://github.com/mihonapp/mihon/pull/3765))
- Support the Fit width, Fit height and Original size scale types and the zoom start position ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3801](https://github.com/mihonapp/mihon/pull/3801))
- Fix preloading and navigation across chapters, and right-to-left navigation ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3823](https://github.com/mihonapp/mihon/pull/3823))
- Add a "None" page transition and fix the background color of animated images ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3843](https://github.com/mihonapp/mihon/pull/3843))
- Improve transition and error pages, dual page animations and border cropping ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3852](https://github.com/mihonapp/mihon/pull/3852))
- Fix navigation directions and long strip bounds, and fade in loaded pages ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3870](https://github.com/mihonapp/mihon/pull/3870))
- Add a "Page flip" transition, a long strip minimum width setting and separate dual page settings, and show wide pages on their own in dual page view ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3886](https://github.com/mihonapp/mihon/pull/3886))
- Fix freezes and running out of memory when jumping between pages with the slider ([@Danidukiyu](https://github.com/Danidukiyu)) ([#3872](https://github.com/mihonapp/mihon/pull/3872))
- Add HDR, ICC color profile and EXIF orientation support ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3933](https://github.com/mihonapp/mihon/pull/3933))
- Add a page gap setting for long strip with gaps and a "Disable zoom out" setting for long strip modes ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3933](https://github.com/mihonapp/mihon/pull/3933))
- Fix a crash on Android 15 and long strip progress and background color ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3949](https://github.com/mihonapp/mihon/pull/3949))
- Keep the reader menu open while moving through pages with the slider ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3956](https://github.com/mihonapp/mihon/pull/3956))
- Show pages progressively while they download ([@wwww-wwww](https://github.com/wwww-wwww)) ([#4023](https://github.com/mihonapp/mihon/pull/4023))
- Lower memory use for animated images and support cropping borders in long strip ([@wwww-wwww](https://github.com/wwww-wwww)) ([#4029](https://github.com/mihonapp/mihon/pull/4029))
- Pair more wide pages in dual page view by comparing them with neighbouring pages ([@wwww-wwww](https://github.com/wwww-wwww)) ([#4051](https://github.com/mihonapp/mihon/pull/4051))
### Changed
- Use a new decoder for images Android can't decode itself, like AVIF, JPEG XL and HEIF, and support JPEG 2000 images ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3786](https://github.com/mihonapp/mihon/pull/3786))
### Improved
- Create and restore backups faster and with less memory ([@NGB-Was-Taken](https://github.com/NGB-Was-Taken), [@AntsyLich](https://github.com/AntsyLich))
### Removed
- Remove "Custom hardware bitmap threshold" and "Custom display profile" advanced settings ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3786](https://github.com/mihonapp/mihon/pull/3786))
## [v0.21.2] - 2026-10-13
### Fixed
- Fix long floating point numbers showing up in Comick progress ([@MajorTanya](https://github.com/MajorTanya)) ([#4090](https://github.com/mihonapp/mihon/pull/4090))
- Fixed updating from v0.20.x failing when the database had duplicate history or tracking entries, or damaged indexes ([@AntsyLich](https://github.com/AntsyLich)) ([`ea2b73a`](https://github.com/mihonapp/mihon/commit/ea2b73a))
- Fixed a crash when a back gesture was still in progress as the reader or a dialog closed ([@AntsyLich](https://github.com/AntsyLich)) ([`43de88c`](https://github.com/mihonapp/mihon/commit/43de88c))
- Fixed Kitsu login and other requests that send data failing with "Failed to bypass Cloudflare" ([@AntsyLich](https://github.com/AntsyLich)) ([`7ae858e`](https://github.com/mihonapp/mihon/commit/7ae858e))
- Fixed tracker start date being off by a day when adding tracking to an entry with read history in some timezones ([@hazptr](https://github.com/hazptr)) ([#4049](https://github.com/mihonapp/mihon/pull/4049))
- Fixed the paged reader crashing when moving to another chapter after a wide page of a neighbouring chapter was split ([@AntsyLich](https://github.com/AntsyLich)) ([`3156b4a`](https://github.com/mihonapp/mihon/commit/3156b4a))
- Fixed a crash when opening the filters of a source with an invalid filter ([@AntsyLich](https://github.com/AntsyLich)) ([`c9e1b6e`](https://github.com/mihonapp/mihon/commit/c9e1b6e))
- Fixed a crash when installing an extension on a device where the system installer can't ask for confirmation ([@AntsyLich](https://github.com/AntsyLich)) ([`2df48e1`](https://github.com/mihonapp/mihon/commit/2df48e1))
- Fixed a crash when tapping in the long strip reader as it closed ([@AntsyLich](https://github.com/AntsyLich)) ([`c371d63`](https://github.com/mihonapp/mihon/commit/c371d63))
- Fixed chapters downloaded before v0.21.0 showing as not downloaded when their name started with a space or dot, was long, or had a non-ASCII scanlator ([@AntsyLich](https://github.com/AntsyLich)) ([`f0f1e4d`](https://github.com/mihonapp/mihon/commit/f0f1e4d))
- Fixed slowdowns and running out of memory when many requests to a site hit a Cloudflare challenge at once ([@tigeryu8900](https://github.com/tigeryu8900), [@AntsyLich](https://github.com/AntsyLich)) ([`5a5ff22`](https://github.com/mihonapp/mihon/commit/5a5ff22))
## [v0.21.1] - 2026-10-12
### Fixed
- Fixed refreshing chapters failing with the app language set to Arabic or another language with its own digits ([@AntsyLich](https://github.com/AntsyLich)) ([`8457fb9`](https://github.com/mihonapp/mihon/commit/8457fb9))
- Fixed a crash on launch on some Samsung devices running Android 9 to 11 ([@AntsyLich](https://github.com/AntsyLich)) ([`abb126a`](https://github.com/mihonapp/mihon/commit/abb126a))
- Fixed the start and resume button going under the taskbar on tablets ([@AntsyLich](https://github.com/AntsyLich)) ([`c279291`](https://github.com/mihonapp/mihon/commit/c279291))
- Fixed a crash when updating an extension while others were installing ([@AntsyLich](https://github.com/AntsyLich)) ([`07b9fb6`](https://github.com/mihonapp/mihon/commit/07b9fb6))
- Fixed library entries without a date added getting their last update time as date added when updating from v0.20.x ([@AntsyLich](https://github.com/AntsyLich)) ([`6dffca1`](https://github.com/mihonapp/mihon/commit/6dffca1))
- Fixed the app closing when WebView's page process crashed ([@AntsyLich](https://github.com/AntsyLich)) ([`649fc83`](https://github.com/mihonapp/mihon/commit/649fc83))
- Fixed installing or updating an extension sometimes cancelling the install of another one ([@AntsyLich](https://github.com/AntsyLich)) ([`ae749bb`](https://github.com/mihonapp/mihon/commit/ae749bb))
- Fixed updating from v0.20.x taking up to hours on libraries with many duplicate entries ([@AntsyLich](https://github.com/AntsyLich)) ([`a999e49`](https://github.com/mihonapp/mihon/commit/a999e49))
- Fixed the app staying on the updating screen for good when updating its data failed, it now shows what went wrong, and updating failing on leftover data of deleted entries ([@AntsyLich](https://github.com/AntsyLich)) ([`9dbb714`](https://github.com/mihonapp/mihon/commit/9dbb714))
- Fixed library updates ignoring the smart update restrictions for entries whose details were never fetched ([@AntsyLich](https://github.com/AntsyLich)) ([`010ac71`](https://github.com/mihonapp/mihon/commit/010ac71))
- Fixed resuming a chapter sometimes opening the next chapter partway through, and saving progress to it, in the right to left reader ([@AntsyLich](https://github.com/AntsyLich)) ([`51b61a3`](https://github.com/mihonapp/mihon/commit/51b61a3))
## [v0.21.0] - 2026-10-12
### Added
- Add `id:` prefix search to remaining trackers (AniList, Bangumi, Kitsu, MangaUpdates, Shikimori, and Hikka) ([@MajorTanya](https://github.com/MajorTanya), [@BrutuZ](https://github.com/BrutuZ)) ([#3776](https://github.com/mihonapp/mihon/pull/3776))
- Allow `id:` to search for slugs on Kitsu ([@MajorTanya](https://github.com/MajorTanya)) ([#3792](https://github.com/mihonapp/mihon/pull/3792))
- Add support for using the user's chosen rating system for Kitsu ([@MajorTanya](https://github.com/MajorTanya)) ([#3818](https://github.com/mihonapp/mihon/pull/3818))
- Fix a crash after logging in to Kitsu caused by the rating system's letter case ([@Secozzi](https://github.com/Secozzi)) ([#3887](https://github.com/mihonapp/mihon/pull/3887))
- Add option to keep loading installed extensions regardless of the allowed content warnings ([@AntsyLich](https://github.com/AntsyLich)) ([#3952](https://github.com/mihonapp/mihon/pull/3952))
- Read chapter dates from the Year, Month, and Day fields of ComicInfo.xml in local source, and write them when downloading chapters ([@MajorTanya](https://github.com/MajorTanya)) ([#3967](https://github.com/mihonapp/mihon/pull/3967))
- Fix wrongly padded years and months above 12 in local source dates ([@MajorTanya](https://github.com/MajorTanya)) ([#3970](https://github.com/mihonapp/mihon/pull/3970))
- Add refresh buttons to trackers in Settings to update displayed usernames & rating systems (where supported) ([@MajorTanya](https://github.com/MajorTanya)) ([#3828](https://github.com/mihonapp/mihon/pull/3828))
- Add option to turn off the hash suffix in downloaded chapter folder names (Settings → Advanced). It stays on for existing installs ([@choppeh](https://github.com/choppeh), [@AntsyLich](https://github.com/AntsyLich)) ([#3966](https://github.com/mihonapp/mihon/pull/3966))
- Fixed downloads not being found when the scanlator name contains a "/" ([@AntsyLich](https://github.com/AntsyLich)) ([`a5d584e`](https://github.com/mihonapp/mihon/commit/a5d584e))
- Show a screen while the app migrates its data after an update ([@AntsyLich](https://github.com/AntsyLich)) ([`fd4f73c`](https://github.com/mihonapp/mihon/commit/fd4f73c))
- Keep the screen up for at least a second so it doesn't just flash by ([@AntsyLich](https://github.com/AntsyLich)) ([`b454c1b`](https://github.com/mihonapp/mihon/commit/b454c1b))
- Add option to skip an app update version ([@AntsyLich](https://github.com/AntsyLich)) ([`fc20410`](https://github.com/mihonapp/mihon/commit/fc20410))
- Add support for [Comick](https://comick.dev) tracker ([@MajorTanya](https://github.com/MajorTanya)) ([#4056](https://github.com/mihonapp/mihon/pull/4056))
### Changed
- Download app updates from the update screen instead of a background job, and remove the "App updates" notification channel ([@AntsyLich](https://github.com/AntsyLich)) ([#3707](https://github.com/mihonapp/mihon/pull/3707))
- Switch app icons to Material Symbols ([@AntsyLich](https://github.com/AntsyLich)) ([#3873](https://github.com/mihonapp/mihon/pull/3873))
- Use the filled icon for the library continue reading button ([@AntsyLich](https://github.com/AntsyLich)) ([`34a33ed`](https://github.com/mihonapp/mihon/commit/34a33ed))
- Move notification, launcher shortcut, bottom navigation and reading mode icons to Material Symbols, bring back filled icons where the switch dropped them, and show a compact grid icon for the compact display modes ([@AntsyLich](https://github.com/AntsyLich)) ([#4057](https://github.com/mihonapp/mihon/pull/4057))
- Replace the NSFW sources toggle with allowed content warnings (Safe, Mixed, 18+) for extensions ([@AntsyLich](https://github.com/AntsyLich)) ([#3951](https://github.com/mihonapp/mihon/pull/3951))
- Restoring a backup over an existing library now keeps existing entry details, the furthest read progress and the earliest date added ([@AntsyLich](https://github.com/AntsyLich)) ([`c67a33f`](https://github.com/mihonapp/mihon/commit/c67a33f))
- Install and update extensions only from a repo whose signing key matches the extension, and reject downloaded extensions not signed with their repo's key ([@AntsyLich](https://github.com/AntsyLich)) ([`0938411`](https://github.com/mihonapp/mihon/commit/0938411))
- Keep the repo shown for an installed extension current when repos are renamed or removed ([@AntsyLich](https://github.com/AntsyLich)) ([`09edf7d`](https://github.com/mihonapp/mihon/commit/09edf7d))
- Show the repo name in extension details, opening the repo's website, instead of the "Open source repo" button ([@AntsyLich](https://github.com/AntsyLich)) ([#3955](https://github.com/mihonapp/mihon/pull/3955))
- Rework the database schema, merging duplicate entries and chapters left by older versions. The merge can't be undone, so create a backup before updating ([@AntsyLich](https://github.com/AntsyLich)) ([#3805](https://github.com/mihonapp/mihon/pull/3805))
- Fixed restoring a backup failing on entries that list a category or excluded scanlator twice ([@AntsyLich](https://github.com/AntsyLich)) ([#4022](https://github.com/mihonapp/mihon/pull/4022))
- List MangaBaka first among trackers ([@AntsyLich](https://github.com/AntsyLich)) ([`dd46413`](https://github.com/mihonapp/mihon/commit/dd46413))
- Show a wavy progress ring while a chapter downloads ([@AntsyLich](https://github.com/AntsyLich)) ([`e562a9b`](https://github.com/mihonapp/mihon/commit/e562a9b))
- Show a confirmation dialog when unfavoriting manga from the manga screen ([@WizaHX](https://github.com/WizaHX)) ([#4073](https://github.com/mihonapp/mihon/pull/4073))
### Improved
- Show the updates and upcoming filter icon as active when filtering by category, or by hidden excluded scanlators in updates ([@Secozzi](https://github.com/Secozzi)) ([#3772](https://github.com/mihonapp/mihon/pull/3772))
- Load extensions in the background to speed up app startup ([@AntsyLich](https://github.com/AntsyLich)) ([#3788](https://github.com/mihonapp/mihon/pull/3788))
- Wait for extensions to load before listing sources, so lists and global search aren't empty right after launch ([@AntsyLich](https://github.com/AntsyLich)) ([#3841](https://github.com/mihonapp/mihon/pull/3841))
- Show scores in MangaUpdates search results (and authors for `id:` prefix searches) ([@MajorTanya](https://github.com/MajorTanya)) ([#3795](https://github.com/mihonapp/mihon/pull/3795))
- Remove whitespace from MAL and MB `id:` prefix search inputs before searching ([@MajorTanya](https://github.com/MajorTanya)) ([#3793](https://github.com/mihonapp/mihon/pull/3793))
- Detect Cloudflare challenges with Cloudflare's `cf-mitigated` header, and fail sooner when a challenge needs user interaction instead of waiting for the timeout ([@tigeryu8900](https://github.com/tigeryu8900)) ([#3842](https://github.com/mihonapp/mihon/pull/3842))
- Show a helpful error message for expired AniList credentials ([@MajorTanya](https://github.com/MajorTanya)) ([#3888](https://github.com/mihonapp/mihon/pull/3888))
- Show installed extensions that couldn't be loaded, with the reason and a copyable stack trace ([@AntsyLich](https://github.com/AntsyLich)) ([#3953](https://github.com/mihonapp/mihon/pull/3953))
- Apply extension trust, repo and content warning changes without restarting the app ([@AntsyLich](https://github.com/AntsyLich)) ([#3954](https://github.com/mihonapp/mihon/pull/3954))
- Use the system document picker when selecting a backup to restore ([@MajorTanya](https://github.com/MajorTanya)) ([#3948](https://github.com/mihonapp/mihon/pull/3948))
- Offer updates for installed extensions that aren't loaded ([@AntsyLich](https://github.com/AntsyLich)) ([`2d86a70`](https://github.com/mihonapp/mihon/commit/2d86a70))
- Show available extension updates on the Browse badge and in the Extensions tab right after launch ([@AntsyLich](https://github.com/AntsyLich)) ([`4f33486`](https://github.com/mihonapp/mihon/commit/4f33486))
- Show which repo an extension comes from in the extensions list, and list an extension once for each repo signing key offering it ([@AntsyLich](https://github.com/AntsyLich)) ([#3955](https://github.com/mihonapp/mihon/pull/3955))
- Speed up loading the library on large libraries ([@AntsyLich](https://github.com/AntsyLich)) ([#3805](https://github.com/mihonapp/mihon/pull/3805))
- Speed up history, updates, the updates widget and browse on large libraries ([@AntsyLich](https://github.com/AntsyLich)) ([#4022](https://github.com/mihonapp/mihon/pull/4022))
- Reduce lag when dragging sliders in settings ([@AntsyLich](https://github.com/AntsyLich)) ([`9409740`](https://github.com/mihonapp/mihon/commit/9409740))
- Speed up refreshing the library when returning to it ([@AntsyLich](https://github.com/AntsyLich)) ([`f027de8`](https://github.com/mihonapp/mihon/commit/f027de8))
- Download a source's chapters faster by starting the next one while the current one finishes ([@AntsyLich](https://github.com/AntsyLich)) ([`730e43f`](https://github.com/mihonapp/mihon/commit/730e43f))
- Load an entry's cover once when several updates show it ([@AntsyLich](https://github.com/AntsyLich)) ([`1f85a5a`](https://github.com/mihonapp/mihon/commit/1f85a5a))
- Speed up the entry details chapter list on entries with many chapters, and the updates list when many chapters are queued for download ([@AntsyLich](https://github.com/AntsyLich)) ([`fb8e640`](https://github.com/mihonapp/mihon/commit/fb8e640))
- Speed up checking which chapters are downloaded ([@AntsyLich](https://github.com/AntsyLich)) ([`6a32938`](https://github.com/mihonapp/mihon/commit/6a32938))
- Stop the entry details screen from refreshing its chapter list in the background while reading ([@AntsyLich](https://github.com/AntsyLich)) ([`7ff9776`](https://github.com/mihonapp/mihon/commit/7ff9776))
- Speed up opening the library on large libraries by loading tracker entries only for tracker filters and sorting ([@AntsyLich](https://github.com/AntsyLich)) ([`940272f`](https://github.com/mihonapp/mihon/commit/940272f))
- Speed up the library query on large libraries ([@AntsyLich](https://github.com/AntsyLich)) ([`5df78e3`](https://github.com/mihonapp/mihon/commit/5df78e3))
- Speed up the statistics screen on large libraries ([@AntsyLich](https://github.com/AntsyLich)) ([`179bfac`](https://github.com/mihonapp/mihon/commit/179bfac))
- Find settings by their group heading in settings search ([@kaosjammo](https://github.com/kaosjammo), [@AntsyLich](https://github.com/AntsyLich)) ([#4044](https://github.com/mihonapp/mihon/pull/4044))
- Predict the next library update from release patterns such as fixed weekdays or a day of the month, ignore chapters dated in the future, and include the predicted date in backups ([@AntsyLich](https://github.com/AntsyLich)) ([`2923816`](https://github.com/mihonapp/mihon/commit/2923816))
- Also follow two days of the month, a weekday of the month, releases in batches and breaks between seasons, and never wait more than 28 days between checks ([@AntsyLich](https://github.com/AntsyLich)) ([`ba44815`](https://github.com/mihonapp/mihon/commit/ba44815))
- Recognize chapter numbers more accurately by looking at neighbouring chapters, and stop taking volume numbers or numbers every chapter name shares as the chapter number ([@AntsyLich](https://github.com/AntsyLich)) ([`e42e010`](https://github.com/mihonapp/mihon/commit/e42e010))
- Move blocking work off the main thread across the app for fewer freezes and stutters ([@AntsyLich](https://github.com/AntsyLich)) ([#4068](https://github.com/mihonapp/mihon/pull/4068))
- Load reader pages in parallel and preload the rest of the chapter and the next chapter, with a "Load ahead only on Wi-Fi" setting to save mobile data ([@AntsyLich](https://github.com/AntsyLich)) ([#2986](https://github.com/mihonapp/mihon/pull/2986))
- Cache extension and source icons so lists show them faster ([@AntsyLich](https://github.com/AntsyLich)) ([`b15af71`](https://github.com/mihonapp/mihon/commit/b15af71))
- Keep screens loaded while they're hidden so switching back to them is faster ([@AntsyLich](https://github.com/AntsyLich)) ([#4071](https://github.com/mihonapp/mihon/pull/4071))
- Speed up the migration list, the extensions list and settings search ([@AntsyLich](https://github.com/AntsyLich)) ([#4071](https://github.com/mihonapp/mihon/pull/4071))
- Cancel the previous tracker search when searching again ([@AntsyLich](https://github.com/AntsyLich)) ([#4071](https://github.com/mihonapp/mihon/pull/4071))
- Do less background work while reading, downloading and updating the library, and when no updates widget is placed ([@AntsyLich](https://github.com/AntsyLich)) ([#4071](https://github.com/mihonapp/mihon/pull/4071))
- Speed up loading extension repos at startup ([@AntsyLich](https://github.com/AntsyLich)) ([`8066b8d`](https://github.com/mihonapp/mihon/commit/8066b8d))
- Speed up opening the entry details screen and showing download progress there and in updates ([@AntsyLich](https://github.com/AntsyLich)) ([`ba16158`](https://github.com/mihonapp/mihon/commit/ba16158))
- Speed up queueing many chapters for download at once ([@AntsyLich](https://github.com/AntsyLich)) ([`f02bf5b`](https://github.com/mihonapp/mihon/commit/f02bf5b))
### Removed
- Remove "Use legacy decoder for long strip reader" advanced setting, since long strip pages no longer load as one big image ([@AntsyLich](https://github.com/AntsyLich))
### Fixed
- Fixed app and extension update check running again on configuration change ([@AntsyLich](https://github.com/AntsyLich)) ([#3708](https://github.com/mihonapp/mihon/pull/3708))
- Fixed MangaBaka user start/finish dates drifting in negative offset timezones ([@MajorTanya](https://github.com/MajorTanya)) ([#3711](https://github.com/mihonapp/mihon/pull/3711))
- Fixed MangaBaka scores being wrong when score step size was set to > 1, and being counted 10 times too high in library sorting by tracker score and in statistics ([@MajorTanya](https://github.com/MajorTanya)) ([#3740](https://github.com/mihonapp/mihon/pull/3740))
- Fixed tracking an entry on Shikimori failing with "Unknown status: 0" when it isn't in your Shikimori list ([@MajorTanya](https://github.com/MajorTanya)) ([#3810](https://github.com/mihonapp/mihon/pull/3810))
- Fixed default category and manga sometimes not getting their category set when restoring a backup ([@Secozzi](https://github.com/Secozzi)) ([#3891](https://github.com/mihonapp/mihon/pull/3891))
- Fixed text between dollar signs disappearing from descriptions and notes ([@renovate](https://github.com/apps/renovate)) ([#3806](https://github.com/mihonapp/mihon/pull/3806))
- Fixed AniList requests failing with rate limit errors by lowering the request rate to AniList's current limit ([@MajorTanya](https://github.com/MajorTanya)) ([#3942](https://github.com/mihonapp/mihon/pull/3942))
- Fixed library search when clicking source name in manga info screen ([@choppeh](https://github.com/choppeh)) ([#4002](https://github.com/mihonapp/mihon/pull/4002))
- Fixed entries being incorrectly marked as duplicates when tracked with some trackers ([@Secozzi](https://github.com/Secozzi)) ([#4008](https://github.com/mihonapp/mihon/pull/4008))
- Fixed the default chapter settings not being applied to entries added to the library from a source by long pressing them ([@AntsyLich](https://github.com/AntsyLich)) ([`f8fff31`](https://github.com/mihonapp/mihon/commit/f8fff31))
- Fixed chapter list updates being partially applied when a refresh fails, and sources that list a chapter twice adding it twice ([@AntsyLich](https://github.com/AntsyLich)) ([`6ee529c`](https://github.com/mihonapp/mihon/commit/6ee529c))
- Fixed downloads of entries from uninstalled extensions sometimes not being recognized ([@AntsyLich](https://github.com/AntsyLich)) ([`0044cc7`](https://github.com/mihonapp/mihon/commit/0044cc7))
- Fixed a removed extension repo coming back when it was removed while repos were refreshing ([@AntsyLich](https://github.com/AntsyLich)) ([`fc5592f`](https://github.com/mihonapp/mihon/commit/fc5592f))
- Fixed being able to rename a category to an empty name ([@AntsyLich](https://github.com/AntsyLich)) ([`8bfa495`](https://github.com/mihonapp/mihon/commit/8bfa495))
- Fixed reading time sometimes being counted twice, and tracker start dates being set to 1970 after removing history ([@AntsyLich](https://github.com/AntsyLich)) ([`553762f`](https://github.com/mihonapp/mihon/commit/553762f))
- Fixed chapters from excluded scanlators failing to open from notifications or links ([@AntsyLich](https://github.com/AntsyLich)) ([`2b4b847`](https://github.com/mihonapp/mihon/commit/2b4b847))
- Fixed queued offline tracker updates being lost when the track was refreshed ([@AntsyLich](https://github.com/AntsyLich)) ([`9a77bae`](https://github.com/mihonapp/mihon/commit/9a77bae))
- Fixed restoring a backup mixing up tracker entries of an entry that was already tracked ([@AntsyLich](https://github.com/AntsyLich)) ([`4b48a84`](https://github.com/mihonapp/mihon/commit/4b48a84))
- Fixed crashes from APIs extensions use being removed from release builds ([@AntsyLich](https://github.com/AntsyLich)) ([`6dfa10f`](https://github.com/mihonapp/mihon/commit/6dfa10f))
- Fixed the download service staying alive after downloads stopped for a network change ([@AntsyLich](https://github.com/AntsyLich)) ([`01d302f`](https://github.com/mihonapp/mihon/commit/01d302f))
- Fixed "database is locked" crashes ([@AntsyLich](https://github.com/AntsyLich)) ([`38e9308`](https://github.com/mihonapp/mihon/commit/38e9308))
- Use WAL journaling on every device ([@AntsyLich](https://github.com/AntsyLich)) ([`61cd786`](https://github.com/mihonapp/mihon/commit/61cd786))
- Fixed global search leaking threads each time it was opened ([@choppeh](https://github.com/choppeh)) ([#4036](https://github.com/mihonapp/mihon/pull/4036))
- Fixed sources getting stuck loading in global and migration search ([@choppeh](https://github.com/choppeh)) ([#4037](https://github.com/mihonapp/mihon/pull/4037))
- Fixed webtoon reader leaving a gap after zooming in split screen or a resized window ([@AntsyLich](https://github.com/AntsyLich)) ([`d1c5c12`](https://github.com/mihonapp/mihon/commit/d1c5c12))
- Fixed download error notifications showing an old time and the previous warning's text ([@AntsyLich](https://github.com/AntsyLich)) ([`f82a7a6`](https://github.com/mihonapp/mihon/commit/f82a7a6))
- Fixed the paused downloads notification not appearing ([@AntsyLich](https://github.com/AntsyLich)) ([`7aacaa3`](https://github.com/mihonapp/mihon/commit/7aacaa3))
- Fixed duplicate page files and failed downloads after pausing and resuming downloads ([@AntsyLich](https://github.com/AntsyLich)) ([`6490c41`](https://github.com/mihonapp/mihon/commit/6490c41))
- Fixed high CPU use on the entry details and updates screens while many chapters are queued for download ([@AntsyLich](https://github.com/AntsyLich)) ([`9a6dea8`](https://github.com/mihonapp/mihon/commit/9a6dea8))
- Fixed backups dropping the names of sources whose extension isn't installed, which left them blank under missing sources when restoring ([@AntsyLich](https://github.com/AntsyLich)) ([`5910a51`](https://github.com/mihonapp/mihon/commit/5910a51))
- Fixed the updates widget showing "Can't show content" when there are no recent updates or app lock is on ([@AntsyLich](https://github.com/AntsyLich)) ([`76b146f`](https://github.com/mihonapp/mihon/commit/76b146f))
- Fixed a crash when range selecting library entries across categories ([@AntsyLich](https://github.com/AntsyLich)) ([`a6ee4bc`](https://github.com/mihonapp/mihon/commit/a6ee4bc))
- Fixed reader tap navigation not responding after a button press turned into a scroll ([@mujina3](https://github.com/mujina3)) ([#3655](https://github.com/mihonapp/mihon/pull/3655))
- Fixed editing the cover of an entry that isn't in the library saying the cover was updated without changing it, and say to add it to the library first instead ([@quickdesh](https://github.com/quickdesh), [@AntsyLich](https://github.com/AntsyLich)) ([#2265](https://github.com/mihonapp/mihon/pull/2265))
- Fixed downloads of duplicate chapters not being deleted when they were marked as read along with a chapter ([@leodyversemilla07](https://github.com/leodyversemilla07), [@AntsyLich](https://github.com/AntsyLich)) ([#3030](https://github.com/mihonapp/mihon/pull/3030))
- Fixed library updates skipping entries whose details were never fetched ([@NarwhalHorns](https://github.com/NarwhalHorns), [@AntsyLich](https://github.com/AntsyLich)) ([#3179](https://github.com/mihonapp/mihon/pull/3179))
- Fixed every library entry being matched as a duplicate of an entry with a blank title ([@NarwhalHorns](https://github.com/NarwhalHorns), [@AntsyLich](https://github.com/AntsyLich)) ([#3308](https://github.com/mihonapp/mihon/pull/3308))
- Fixed marking chapters as read deleting their downloads in categories excluded from "Delete after reading" ([@mliem2k](https://github.com/mliem2k)) ([#3558](https://github.com/mihonapp/mihon/pull/3558))
- Fixed local source entries missing from global search ([@mugetau](https://github.com/mugetau)) ([#3784](https://github.com/mihonapp/mihon/pull/3784))
- Fixed the Updates screen hiding the last update time and pull to refresh when it has no chapters, and saying there are no recent updates when filters hide them all ([@AMendoza180890](https://github.com/AMendoza180890), [@AntsyLich](https://github.com/AntsyLich)) ([#3790](https://github.com/mihonapp/mihon/pull/3790))
- Fixed downloads of chapters removed by the source getting stuck in the download queue ([@JoJoKorok](https://github.com/JoJoKorok), [@AntsyLich](https://github.com/AntsyLich)) ([#3845](https://github.com/mihonapp/mihon/pull/3845))
- Fixed the stored download index overwriting the startup rescan, which could show downloaded chapters as not downloaded ([@choppeh](https://github.com/choppeh), [@AntsyLich](https://github.com/AntsyLich)) ([#3968](https://github.com/mihonapp/mihon/pull/3968))
- Fixed local chapters no longer being listed after one archive couldn't be read ([@a-biad](https://github.com/a-biad), [@AntsyLich](https://github.com/AntsyLich)) ([#3997](https://github.com/mihonapp/mihon/pull/3997))
- Fixed "Hide missing chapter indicators" not hiding the indicator in chapter headers ([@kaosjammo](https://github.com/kaosjammo)) ([#4039](https://github.com/mihonapp/mihon/pull/4039))
- Fixed a chapter being downloaded a second time when downloads were stopped right as it finished ([@AntsyLich](https://github.com/AntsyLich)) ([`6115783`](https://github.com/mihonapp/mihon/commit/6115783))
- Fixed the chapter selection being cleared when new chapters were downloaded automatically after a refresh ([@AntsyLich](https://github.com/AntsyLich)) ([`a82d27c`](https://github.com/mihonapp/mihon/commit/a82d27c))
- Fixed actions started from a dialog sometimes not running when the dialog closed right away, Kitsu, Shikimori, MangaBaka, Bangumi and Hikka sometimes refreshing their login twice at once, adding an extension repo served over HTTP failing, and cancelling an extension install not stopping its download ([@AntsyLich](https://github.com/AntsyLich)) ([#4068](https://github.com/mihonapp/mihon/pull/4068))
- Fixed the next library update staying "Soon" after new chapters were found, and chapters that came out early, late or were dated earlier by the source pushing the next check back ([@AntsyLich](https://github.com/AntsyLich)) ([`ba44815`](https://github.com/mihonapp/mihon/commit/ba44815))
- Fixed long strip side padding being counted in zoom bounds ([@WizaHX](https://github.com/WizaHX)) ([#4072](https://github.com/mihonapp/mihon/pull/4072))
- Fixed long strip stuttering between pages and using a lot of memory with very tall pages ([@AntsyLich](https://github.com/AntsyLich), [@matheuswr89](https://github.com/matheuswr89))
### Other
- Show "Nightly" instead of "Beta" for preview builds and "FOSS" for FOSS builds in About ([@AntsyLich](https://github.com/AntsyLich)) ([#3760](https://github.com/mihonapp/mihon/pull/3760))
- Migrate Kitsu tracker to Kitsu's GraphQL API ([@MajorTanya](https://github.com/MajorTanya)) ([#3792](https://github.com/mihonapp/mihon/pull/3792))
- Fixed Kitsu search showing novel results ([@MajorTanya](https://github.com/MajorTanya)) ([#4069](https://github.com/mihonapp/mihon/pull/4069))
- Extensions can only get Application, Context, Json, ProtoBuf, XML, NetworkHelper and JavaScriptEngine from Injekt ([@AntsyLich](https://github.com/AntsyLich)) ([#3965](https://github.com/mihonapp/mihon/pull/3965))
- Use Apollo GraphQL for AniList, Kitsu, Shikimori and Suwayomi tracker APIs ([@MajorTanya](https://github.com/MajorTanya), [@Syer10](https://github.com/Syer10)) ([#3827](https://github.com/mihonapp/mihon/pull/3827))
## [v0.20.4] - 2026-08-05
### Fixed
- Fixed some dates being wrongly shown as "Today" or otherwise closer in time ([@MajorTanya](https://github.com/MajorTanya)) ([#3696](https://github.com/mihonapp/mihon/pull/3696))
## [v0.20.3] - 2026-08-04
### Added
- Add Category filtering for the Updates tab ([@MajorTanya](https://github.com/MajorTanya)) ([#3589](https://github.com/mihonapp/mihon/pull/3589))
- Add Category filtering for the Upcoming calendar ([@MajorTanya](https://github.com/MajorTanya)) ([#3607](https://github.com/mihonapp/mihon/pull/3607))
### Changed
- Update default user agent to Chrome 149 ([@AntsyLich](https://github.com/AntsyLich)) ([#3678](https://github.com/mihonapp/mihon/pull/3678))
### Improved
- Spoof `Sec-CH-UA` client hints in WebView to match the user agent ([@AntsyLich](https://github.com/AntsyLich)) ([#3678](https://github.com/mihonapp/mihon/pull/3678))
### Fixed
- Fixed extension installation with shizuku installer ([@NGB-Was-Taken](https://github.com/NGB-Was-Taken)) ([#3676](https://github.com/mihonapp/mihon/pull/3676))
- Fixed `X-Requested-With` spoofing leaking to unrelated callers ([@AntsyLich](https://github.com/AntsyLich)) ([#3678](https://github.com/mihonapp/mihon/pull/3678))
- Fixed reader loading indefinitely in some scenarios ([@AntsyLich](https://github.com/AntsyLich)) ([#3686](https://github.com/mihonapp/mihon/pull/3686))
- Fix MyAnimeList error for list entries with partial start/finish reading dates by setting those dates to either Jan 1st or 1st of the month ([@MajorTanya](https://github.com/MajorTanya)) ([#3573](https://github.com/mihonapp/mihon/pull/3573))
## [v0.20.2] - 2026-08-01
### Added
- Support resumable image downloads if supported by source ([@xMohnad](https://github.com/xMohnad)) ([#3167](https://github.com/mihonapp/mihon/pull/3167))
- Fix HTTP Error 416 not being handled properly ([@AntsyLich](https://github.com/AntsyLich)) ([#3563](https://github.com/mihonapp/mihon/pull/3563))
- Fix incorrect progress when resuming downloads ([@xMohnad](https://github.com/xMohnad)) ([#3616](https://github.com/mihonapp/mihon/pull/3616))
- Add Tokyo Night Theme ([@xMohnad](https://github.com/xMohnad)) ([#3502](https://github.com/mihonapp/mihon/pull/3502))
### Changed
- Detect Shizuku with permission check ([@Small-Ku](https://github.com/Small-Ku)) ([#3565](https://github.com/mihonapp/mihon/pull/3565))
- Check for app and extension update on every cold start ([@AntsyLich](https://github.com/AntsyLich)) ([#3658](https://github.com/mihonapp/mihon/pull/3658))
### Improved
- Library search ([@NGB-Was-Taken](https://github.com/NGB-Was-Taken)) ([#3554](https://github.com/mihonapp/mihon/pull/3554))
- Support logical operators (`&&`, `||`, `-`), implicit `AND` separators, and nested expressions with `()`
- Add comparison operators (`>`, `>=`, `<`, `<=`, `=`) for `id`, `added`, `fetchinterval` / `fi`, `nextupdate` / `nu`, `unread`, `read`, and `total` (Uses `yyyy-MM-dd` dates for `added` and `nextupdate`)
- Support field-specific prefixes (`title`, `author`, `artist`, `description` / `desc`, `genre` / `tag`, `source` / `src`, `source_id` / `src_id`, `notes` / `note`, `language` / `lang`) and empty field matching (`""`, `''`)
- Always use Private installer to update privately installed extensions ([@MajorTanya](https://github.com/MajorTanya)) ([#3614](https://github.com/mihonapp/mihon/pull/3614))
- App update prompt flow experience ([@AntsyLich](https://github.com/AntsyLich)) ([#3669](https://github.com/mihonapp/mihon/pull/3669))
### Fixed
- Fixed app bars remaining visible after changing pages by tapping in the paged reader after using the chapter navigator slider ([@AntsyLich](https://github.com/AntsyLich)) ([#3567](https://github.com/mihonapp/mihon/pull/3567))
- Fixed MangaBaka User Agent string ([@MajorTanya](https://github.com/MajorTanya)) ([#3578](https://github.com/mihonapp/mihon/pull/3578))
- Fixed extension installation with shizuku installer ([@NGB-Was-Taken](https://github.com/NGB-Was-Taken)) ([#3630](https://github.com/mihonapp/mihon/pull/3630))
- Fixed backup restore dropping library entries when the backup contains duplicate chapters ([@na-ji](https://github.com/na-ji)) ([#3667](https://github.com/mihonapp/mihon/pull/3667))
## [v0.20.1] - 2026-07-09
### Added
@@ -700,7 +467,7 @@ Same as v0.19.6
- Fix MigratorTest after updating to Kotlin 2 ([@cuong-tran](https://github.com/cuong-tran)) ([#896](https://github.com/mihonapp/mihon/pull/896))
- Add MigratorTest to build script ([@cuong-tran](https://github.com/cuong-tran)) ([#896](https://github.com/mihonapp/mihon/pull/896))
- Fix UI freeze after migration ([@AntsyLich](https://github.com/AntsyLich)) ([`3f1d28c`](https://github.com/mihonapp/mihon/commit/3f1d28c3833e6b868152149ed02b3fb8c54eccef))
- Fix some migrations never running ([@MajorTanya](https://github.com/MajorTanya), [@AntsyLich](https://github.com/AntsyLich)) ([#1114](https://github.com/mihonapp/mihon/pull/1114))
- Fix some migrations never running ([@MajorTanya](https://github.com/MajorTanya), [@AntsyLich](https://github.com/AntsyLich)) ([#1030](https://github.com/mihonapp/mihon/pull/1030))
- Add ProGuard rule to keep `mihon` namespace classes ([@MajorTanya](https://github.com/MajorTanya)) ([#605](https://github.com/mihonapp/mihon/pull/605))
- Use gradle plugins to share build configuration instead of subprojects ([@AntsyLich](https://github.com/AntsyLich)) ([`e448e40`](https://github.com/mihonapp/mihon/commit/e448e40406e8d9916120a278e42829a6f1b25a7a))
- Remove dependency on compose material 2 components ([@AntsyLich](https://github.com/AntsyLich)) ([`fb94230`](https://github.com/mihonapp/mihon/commit/fb9423028eb017c110cb805f2d0601e5b02e50f9))
@@ -812,13 +579,7 @@ Same as v0.19.6
- Branding to Mihon ([@AntsyLich](https://github.com/AntsyLich))
- Minimum supported Android version to 8 ([@AntsyLich](https://github.com/AntsyLich)) ([`dfb3091`](https://github.com/mihonapp/mihon/commit/dfb3091e380dda3e9bfb64bf5c9a685cf3a03d0e))
[unreleased]: https://github.com/mihonapp/mihon/compare/v0.21.2...main
[v0.21.2]: https://github.com/mihonapp/mihon/compare/v0.21.1...v0.21.2
[v0.21.1]: https://github.com/mihonapp/mihon/compare/v0.21.0...v0.21.1
[v0.21.0]: https://github.com/mihonapp/mihon/compare/v0.20.4...v0.21.0
[v0.20.4]: https://github.com/mihonapp/mihon/compare/v0.20.3...v0.20.4
[v0.20.3]: https://github.com/mihonapp/mihon/compare/v0.20.2...v0.20.3
[v0.20.2]: https://github.com/mihonapp/mihon/compare/v0.20.1...v0.20.2
[unreleased]: https://github.com/mihonapp/mihon/compare/v0.20.1...main
[v0.20.1]: https://github.com/mihonapp/mihon/compare/v0.20.0...v0.20.1
[v0.20.0]: https://github.com/mihonapp/mihon/compare/v0.19.9...v0.20.0
[v0.19.9]: https://github.com/mihonapp/mihon/compare/v0.19.8...v0.19.9
+3 -3
View File
@@ -29,7 +29,7 @@ Discover and read manga, webtoons, comics, and more – easier than ever on your
* Local reading of content.
* A configurable reader with multiple viewers, reading directions and other settings.
* Tracker support: [MangaBaka](https://mangabaka.org), [MyAnimeList](https://myanimelist.net/), [AniList](https://anilist.co/), [Kitsu](https://kitsu.app/), [MangaUpdates](https://mangaupdates.com), [Shikimori](https://shikimori.one), [Bangumi](https://bgm.tv/), and [Hikka](https://hikka.io/) support.
* Tracker support: [MyAnimeList](https://myanimelist.net/), [AniList](https://anilist.co/), [Kitsu](https://kitsu.app/), [MangaUpdates](https://mangaupdates.com), [Shikimori](https://shikimori.one), [Bangumi](https://bgm.tv/) and [Hikka](https://hikka.io/) support.
* Categories to organize your library.
* Light and dark themes.
* Schedule updating your library for new chapters.
@@ -49,8 +49,8 @@ Before reporting a new issue, take a look at the [FAQ](https://mihon.app/docs/fa
### Repositories
[![mihonapp/website - GitHub](https://github-stats-extended.vercel.app/api/pin/?username=mihonapp&repo=website&bg_color=161B22&text_color=c9d1d9&title_color=0877d2&icon_color=0877d2&border_radius=8&hide_border=true&description_lines_count=2)](https://github.com/mihonapp/website/)
[![mihonapp/bitmap.kt - GitHub](https://github-stats-extended.vercel.app/api/pin/?username=mihonapp&repo=bitmap.kt&bg_color=161B22&text_color=c9d1d9&title_color=0877d2&icon_color=0877d2&border_radius=8&hide_border=true&description_lines_count=2)](https://github.com/mihonapp/bitmap.kt/)
[![mihonapp/website - GitHub](https://github-readme-stats.vercel.app/api/pin/?username=mihonapp&repo=website&bg_color=161B22&text_color=c9d1d9&title_color=0877d2&icon_color=0877d2&border_radius=8&hide_border=true&description_lines_count=2)](https://github.com/mihonapp/website/)
[![mihonapp/bitmap.kt - GitHub](https://github-readme-stats.vercel.app/api/pin/?username=mihonapp&repo=bitmap.kt&bg_color=161B22&text_color=c9d1d9&title_color=0877d2&icon_color=0877d2&border_radius=8&hide_border=true&description_lines_count=2)](https://github.com/mihonapp/bitmap.kt/)
### Credits
+27 -131
View File
@@ -1,25 +1,20 @@
import com.android.build.api.variant.ApplicationVariant
import com.android.build.api.variant.BuildConfigField
import com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsExtension
import mihon.gradle.Config
import mihon.gradle.getCurrentTime
import mihon.gradle.getBuildTime
import mihon.gradle.getLatestCommitCount
import mihon.gradle.getLatestCommitSha
import mihon.gradle.getLatestCommitTime
import mihon.gradle.tasks.ReplaceShortcutsPlaceholderTask
import java.io.FileInputStream
import java.util.Properties
import kotlin.io.encoding.Base64
plugins {
alias(mihonx.plugins.android.application)
alias(mihonx.plugins.compose)
alias(mihonx.plugins.spotless)
alias(libs.plugins.metro)
alias(libs.plugins.aboutLibraries)
alias(libs.plugins.androidx.baselineProfile)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.apollo)
}
if (Config.includeTelemetry) {
@@ -29,7 +24,7 @@ if (Config.includeTelemetry) {
}
}
val keystorePropertiesFile = layout.settingsDirectory.file("keystore.properties").asFile
val keystorePropertiesFile = rootProject.file("keystore.properties")
android {
namespace = "eu.kanade.tachiyomi"
@@ -37,9 +32,12 @@ android {
defaultConfig {
applicationId = "app.mihon"
versionCode = 38
versionName = "0.21.2"
versionCode = 26
versionName = "0.20.1"
buildConfigField("String", "COMMIT_COUNT", "\"${getLatestCommitCount()}\"")
buildConfigField("String", "COMMIT_SHA", "\"${getLatestCommitSha()}\"")
buildConfigField("String", "BUILD_TIME", "\"${getBuildTime(useLatestCommitTime = false)}\"")
buildConfigField("boolean", "TELEMETRY_INCLUDED", "${Config.includeTelemetry}")
buildConfigField("boolean", "UPDATER_ENABLED", "${Config.enableUpdater}")
@@ -47,9 +45,14 @@ android {
}
if (System.getenv("MIHON_GITHUB_RELEASE").toBoolean()) {
val tempStoreFile = file(System.getenv("RUNNER_TEMP")).resolve("antsy.keystore")
val storeFileBytes = System.getenv("storeFileBase64").let(Base64::decode)
tempStoreFile.outputStream().use { it.write(storeFileBytes) }
signingConfigs {
named("debug") {
storeFile = file(System.getenv("storeFile"))
storeFile = tempStoreFile
storePassword = System.getenv("storePassword")
keyAlias = System.getenv("keyAlias")
keyPassword = System.getenv("keyPassword")
@@ -71,6 +74,7 @@ android {
buildTypes {
val debug = getByName("debug") {
applicationIdSuffix = ".dev"
versionNameSuffix = "-${getLatestCommitCount()}"
isPseudoLocalesEnabled = true
}
val release = getByName("release") {
@@ -82,6 +86,8 @@ android {
isProfileable = true
proguardFiles("proguard-android-optimize.txt", "proguard-rules.pro")
buildConfigField("String", "BUILD_TIME", "\"${getBuildTime(useLatestCommitTime = true)}\"")
}
val commonMatchingFallbacks = listOf(release.name)
@@ -93,12 +99,16 @@ android {
matchingFallbacks.addAll(commonMatchingFallbacks)
}
create("nightly") {
create("preview") {
initWith(release)
applicationIdSuffix = ".debug"
versionNameSuffix = debug.versionNameSuffix
matchingFallbacks.addAll(commonMatchingFallbacks)
buildConfigField("String", "BUILD_TIME", "\"${getBuildTime(useLatestCommitTime = false)}\"")
}
create("benchmark") {
initWith(release)
@@ -108,18 +118,10 @@ android {
matchingFallbacks.addAll(commonMatchingFallbacks)
}
if (Config.includeTelemetry) {
configureEach {
configure<CrashlyticsExtension> {
mappingFileUploadEnabled = Config.uploadCrashlyticsMapping
}
}
}
}
sourceSets {
getByName("nightly").res.directories.add("src/debug/res")
getByName("preview").res.directories.add("src/debug/res")
getByName("benchmark").res.directories.add("src/debug/res")
}
@@ -205,12 +207,8 @@ dependencies {
baselineProfile(projects.baselineProfile)
implementation(projects.i18n)
implementation(projects.icons.customMaterialSymbols)
implementation(projects.icons.materialSymbols)
implementation(projects.icons.simpleIcons)
implementation(projects.core.archive)
implementation(projects.core.common)
implementation(projects.core.metro)
implementation(projects.coreMetadata)
implementation(projects.sourceApi)
implementation(projects.sourceLocal)
@@ -224,23 +222,25 @@ dependencies {
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.foundation)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.materialIcons)
implementation(libs.androidx.compose.animation)
implementation(libs.androidx.compose.animationGraphics)
debugImplementation(libs.androidx.compose.uiTooling)
implementation(libs.androidx.compose.uiToolingPreview)
implementation(libs.androidx.compose.uiUtil)
implementation(libs.androidx.navigationEvent)
implementation(libs.androidx.interpolator)
implementation(libs.androidx.paging.runtime)
implementation(libs.androidx.paging.compose)
implementation(libs.androidx.sqlite.bundled)
implementation(libs.kotlin.reflect)
implementation(libs.bundles.kotlinx.coroutines)
implementation(libs.kotlinx.datetime)
implementation(libs.sqldelight.async)
// AndroidX libraries
implementation(libs.androidx.annotation)
@@ -281,9 +281,6 @@ dependencies {
// Dependency injection
implementation(libs.injekt)
implementation(libs.metro.runtime)
implementation(libs.metrox.viewmodel)
implementation(libs.metrox.viewmodel.compose)
// Image loading
implementation(libs.bundles.coil)
@@ -292,8 +289,6 @@ dependencies {
}
implementation(libs.image.decoder)
implementation(libs.webgpuviewer)
// UI libraries
implementation(libs.material)
implementation(libs.flexibleAdapter)
@@ -321,10 +316,6 @@ dependencies {
// String similarity
implementation(libs.stringSimilarity)
// GraphQL generation
implementation(libs.apollo)
implementation(libs.apollo.adapters)
// Tests
testImplementation(libs.bundles.test)
testRuntimeOnly(libs.junit.platform.launcher)
@@ -336,102 +327,7 @@ dependencies {
testImplementation(libs.kotlinx.coroutines.test)
}
apollo {
val schemaBasePath = "src/main/graphql/mihon/graphql"
service("anilist") {
packageName.set("mihon.graphql.anilist")
val srcDir = "$schemaBasePath/anilist"
srcDir(file(srcDir))
introspection {
endpointUrl.set("https://graphql.anilist.co")
schemaFile.set(file("$srcDir/anilist.graphqls"))
}
// ISO 3166-1 alpha-2 country code
mapScalarToKotlinString("CountryCode")
}
service("kitsu") {
packageName.set("mihon.graphql.kitsu")
val srcDir = "$schemaBasePath/kitsu"
srcDir(file(srcDir))
android.defaultConfig.versionCode
introspection {
// Kitsu doesn't like requests without a UA
headers.put(
"User-Agent",
"Mihon v${android.defaultConfig.versionName} (${android.defaultConfig.applicationId})",
)
endpointUrl.set("https://kitsu.app/api/graphql")
schemaFile.set(file("$srcDir/kitsu.graphqls"))
}
// A date, expressed as an ISO8601 string
mapScalarToKotlinString("Date")
// An ISO 8601-encoded datetime
mapScalar("ISO8601DateTime", "kotlin.time.Instant", "com.apollographql.adapter.core.KotlinInstantAdapter")
// A loose key-value map in GraphQL
mapScalar(
"Map",
"kotlin.collections.Map<String, Any?>",
"mihon.graphql.kitsu.adapter.KitsuMapAdapter",
)
}
service("shikimori") {
packageName.set("mihon.graphql.shikimori")
val srcDir = "$schemaBasePath/shikimori"
srcDir(file(srcDir))
introspection {
endpointUrl.set("https://shikimori.io/api/graphql")
schemaFile.set(file("$srcDir/shikimori.graphqls"))
}
// An ISO 8601-encoded date
mapScalarToKotlinString("ISO8601Date")
}
service("suwayomi") {
packageName.set("mihon.graphql.suwayomi")
val srcDir = "$schemaBasePath/suwayomi"
srcDir(file(srcDir))
introspection {
endpointUrl.set("http://localhost:4567/api/graphql")
schemaFile.set(file("$srcDir/suwayomi.graphqls"))
}
}
}
val latestCommitCount = getLatestCommitCount()
val latestCommitSha = getLatestCommitSha()
val latestCommitTime = getLatestCommitTime()
val currentTime = getCurrentTime()
fun ApplicationVariant.buildConfigField(type: String, name: String, value: Provider<String>) {
buildConfigFields?.put(name, value.map { BuildConfigField(type, it, null) })
}
androidComponents {
onVariants { variant ->
val isUnstableBuild = variant.buildType == "debug" || variant.buildType == "nightly"
val buildTime = if (isUnstableBuild) currentTime else latestCommitTime
variant.buildConfigField("String", "COMMIT_COUNT", latestCommitCount.map { "\"$it\"" })
variant.buildConfigField("String", "COMMIT_SHA", latestCommitSha.map { "\"$it\"" })
variant.buildConfigField("String", "BUILD_TIME", buildTime.map { "\"$it\"" })
if (isUnstableBuild) {
variant.outputs.forEach { output ->
val versionName = output.versionName.get()
output.versionName.set(latestCommitCount.map { "$versionName-$it" })
}
}
}
onVariants { variant ->
val resSource = variant.sources.res ?: return@onVariants
+11 -16
View File
@@ -1,8 +1,8 @@
-dontobfuscate
# Injekt reads the injected type from the generic superclass of its type tokens
-keepattributes Signature
-keep,allowshrinking class * extends uy.kohesive.injekt.api.FullTypeReference
-keep,allowoptimization class eu.kanade.**
-keep,allowoptimization class tachiyomi.**
-keep,allowoptimization class mihon.**
# Keep common dependencies used in extensions
-keep,allowoptimization class androidx.preference.** { public protected *; }
@@ -18,15 +18,19 @@
-keep,allowoptimization class uy.kohesive.injekt.** { public protected *; }
-keep,allowoptimization class com.squareup.zstd.** { public protected *; }
# From extensions-lib
-keep,allowoptimization class eu.kanade.tachiyomi.network.interceptor.RateLimitInterceptorKt { public protected *; }
-keep,allowoptimization class eu.kanade.tachiyomi.network.interceptor.SpecificHostRateLimitInterceptorKt { public protected *; }
-keep,allowoptimization class eu.kanade.tachiyomi.network.NetworkHelper { public protected *; }
-keep,allowoptimization class eu.kanade.tachiyomi.network.OkHttpExtensionsKt { public protected *; }
-keep,allowoptimization class eu.kanade.tachiyomi.network.RequestsKt { public protected *; }
-keep,allowoptimization class eu.kanade.tachiyomi.AppInfo { public protected *; }
-keepclassmembers class * implements java.io.Serializable {
java.lang.Object writeReplace();
java.lang.Object readResolve();
}
# Provided by the OEM at runtime when supported
-dontwarn androidx.window.extensions.**
-dontwarn androidx.window.sidecar.**
##---------------Begin: proguard configuration for RxJava 1.x ----------
-dontwarn sun.misc.**
@@ -82,12 +86,3 @@
# Firebase
-keep class com.google.firebase.installations.** { *; }
-keep interface com.google.firebase.installations.** { *; }
# KotlinX Datetime
-keep,allowoptimization class kotlinx.datetime.** { public protected *; }
# Methods called by Shizuku only
-keepclassmembers class mihon.app.shizuku.ShellInterface {
public <init>();
public void destroy();
}
-11
View File
@@ -198,17 +198,6 @@
<data android:host="shikimori-auth" />
<data android:host="hikka-auth" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<!-- Comick requires native reverse-domain schemes -->
<data android:scheme="app.mihon" />
<data android:host="comick-auth" />
</intent-filter>
</activity>
<receiver
@@ -1,7 +1,7 @@
package mihon.app.shizuku;
interface IShellInterface {
void install(in AssetFileDescriptor apk, in IntentSender intentSender) = 1;
void install(in AssetFileDescriptor apk) = 1;
void destroy() = 16777114;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,15 +0,0 @@
mutation AniListAddManga(
$manga_id: Int!,
$progress: Int!,
$status: MediaListStatus!,
$private: Boolean!,
) {
SaveMediaListEntry (
mediaId: $manga_id,
progress: $progress,
status: $status,
private: $private,
) {
id
}
}
@@ -1,9 +0,0 @@
mutation AniListDeleteManga(
$library_id: Int!,
) {
DeleteMediaListEntry(
id: $library_id,
) {
deleted
}
}
@@ -1,9 +0,0 @@
query AniListGetCurrentUser {
Viewer {
id
name
mediaListOptions {
scoreFormat
}
}
}
@@ -1,31 +0,0 @@
query AniListGetLibManga(
$user_id: Int!,
$manga_id: Int!,
) {
Page {
mediaList(
userId: $user_id,
type: MANGA,
mediaId: $manga_id,
) {
id
status
scoreRaw: score(format: POINT_100)
progress
private
startedAt {
year
month
day
}
completedAt {
year
month
day
}
media {
...MangaFragment
}
}
}
}
@@ -1,13 +0,0 @@
query AniListGetMangaDetails(
$manga_id: Int!,
) {
Page (perPage: 1) {
media(
id: $manga_id,
type: MANGA,
format_not_in: [NOVEL],
) {
...MangaFragment
}
}
}
@@ -1,13 +0,0 @@
query AniListSearchManga(
$search: String!,
) {
Page (perPage: 50) {
media(
search: $search,
type: MANGA,
format_not_in: [NOVEL],
) {
...MangaFragment
}
}
}
@@ -1,21 +0,0 @@
mutation AniListUpdateManga(
$library_id: Int!,
$progress: Int!,
$status: MediaListStatus!,
$private: Boolean!,
$score: Int!,
$startedAt: FuzzyDateInput!,
$completedAt: FuzzyDateInput!,
) {
SaveMediaListEntry(
id: $library_id,
progress: $progress,
status: $status,
private: $private,
scoreRaw: $score,
startedAt: $startedAt,
completedAt: $completedAt,
) {
id
}
}
@@ -1,30 +0,0 @@
fragment MangaFragment on Media {
id
staff {
edges {
role
node {
name {
userPreferred
}
}
}
}
title {
userPreferred
}
coverImage {
large
}
format
countryOfOrigin
status
chapters
description
startDate {
year
month
day
}
averageScore
}
File diff suppressed because one or more lines are too long
@@ -1,27 +0,0 @@
mutation KitsuAddLibManga(
$media_id: ID!,
$status: LibraryEntryStatusEnum!,
$progress: Int!,
$private: Boolean!,
$rating: Int,
) {
libraryEntry {
create(
input: {
mediaId: $media_id,
mediaType: MANGA,
status: $status,
progress: $progress,
private: $private,
rating: $rating,
}
) {
errors {
message
}
libraryEntry {
id
}
}
}
}
@@ -1,18 +0,0 @@
mutation KitsuDeleteLibEntry(
$library_id: ID!,
) {
libraryEntry {
delete(
input: {
id: $library_id,
}
) {
errors {
message
}
libraryEntry {
id
}
}
}
}
@@ -1,18 +0,0 @@
query KitsuFindLibManga(
$remote_id: ID!,
) {
findMangaById(
id: $remote_id,
) {
...MangaFragment
myLibraryEntry {
id
private
progress
rating
status
startedAt
finishedAt
}
}
}
@@ -1,9 +0,0 @@
query KitsuGetCurrentAccount {
currentAccount {
id
ratingSystem
profile {
name
}
}
}
@@ -1,9 +0,0 @@
query KitsuGetMangaDetailsById(
$id: ID!,
) {
findMangaById(
id: $id,
) {
...MangaFragment
}
}
@@ -1,9 +0,0 @@
query KitsuGetMangaDetailsBySlug(
$slug: String!,
) {
findMangaBySlug(
slug: $slug,
) {
...MangaFragment
}
}
@@ -1,12 +0,0 @@
query KitsuSearchMangaByTitle(
$query: String!,
) {
searchMangaByTitle(
title: $query,
first: 20,
) {
nodes {
...MangaFragment
}
}
}
@@ -1,30 +0,0 @@
mutation KitsuUpdateLibManga(
$library_id: ID!,
$status: LibraryEntryStatusEnum!,
$progress: Int!,
$private: Boolean!,
$rating: Int,
$startedAt: ISO8601DateTime,
$finishedAt: ISO8601DateTime,
) {
libraryEntry {
update(
input: {
id: $library_id,
status: $status,
progress: $progress,
private: $private,
rating: $rating,
startedAt: $startedAt,
finishedAt: $finishedAt,
}
) {
errors {
message
}
libraryEntry {
id
}
}
}
}
@@ -1,31 +0,0 @@
fragment MangaFragment on Manga {
id
titles {
preferred
}
chapterCount
staff(first: 5) {
nodes {
role
person {
name
}
}
}
posterImage {
views(names: "small") {
name
url
}
original {
name
url
}
}
description(locales: "en")
status
subtype
startDate
slug
averageRating
}
File diff suppressed because it is too large Load Diff
@@ -1,22 +0,0 @@
fragment MangaFragment on Manga {
id
name
chapters
kind
poster {
mainUrl
}
score
url
status
airedOn {
date
}
description
personRoles {
person {
name
}
rolesEn
}
}
@@ -1,6 +0,0 @@
query ShikimoriGetCurrentUser {
currentUser {
id
nickname
}
}
@@ -1,19 +0,0 @@
query ShikimoriGetLibManga(
$remote_id: String!,
) {
mangas(
ids: $remote_id,
limit: 1,
) {
id
url
name
chapters
userRate {
id
chapters
status
score
}
}
}
@@ -1,11 +0,0 @@
query ShikimoriGetMangaDetails(
$query: String!,
) {
mangas(
ids: $query,
limit: 1,
kind: "!light_novel,!novel",
) {
...MangaFragment
}
}
@@ -1,11 +0,0 @@
query ShikimoriSearchManga(
$search: String!
) {
mangas(
search: $search,
limit: 20,
kind: "!light_novel,!novel",
) {
...MangaFragment
}
}
File diff suppressed because it is too large Load Diff
@@ -1,15 +0,0 @@
fragment MangaFragment on MangaType {
description
id
status
thumbnailUrl
title
url
chapters {
totalCount
}
latestReadChapter {
chapterNumber
}
unreadCount
}
@@ -1,9 +0,0 @@
query SuwayomiGetManga(
$mangaId: Int!,
) {
manga(
id: $mangaId,
) {
...MangaFragment
}
}
@@ -1,30 +0,0 @@
query SuwayomiGetMangaUnreadChapters(
$mangaId: Int!,
$chapterNumber: Float!,
) {
chapters(
condition: {
mangaId: $mangaId,
isRead: false,
},
filter: {
and: [
{
chapterNumber: {
lessThanOrEqualTo: $chapterNumber
}
},
{
chapterNumber: {
greaterThan: 0
}
},
]
},
){
nodes {
id
chapterNumber
}
}
}
@@ -1,14 +0,0 @@
mutation SuwayomiMarkChaptersRead(
$chapters: [Int!]!,
) {
updateChapters(
input: {
ids: $chapters,
patch: {
isRead: true,
},
},
) {
__typename
}
}
@@ -1,22 +0,0 @@
mutation SuwayomiMarkAndDeleteChapters(
$chapters: [Int!]!,
) {
updateChapters(
input: {
ids: $chapters,
patch: {
isRead: true,
},
},
) {
__typename
}
deleteDownloadedChapters(
input: {
ids: $chapters,
},
) {
__typename
}
}
@@ -1,11 +0,0 @@
mutation SuwayomiUpdateMangaProgress(
$mangaId: Int!,
) {
trackProgress(
input: {
mangaId: $mangaId,
},
) {
__typename
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,13 @@
package eu.kanade.core.util
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import tachiyomi.domain.source.service.SourceManager
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
@Composable
fun ifSourcesLoaded(): Boolean {
return remember { Injekt.get<SourceManager>().isInitialized }.collectAsState().value
}
@@ -0,0 +1,211 @@
package eu.kanade.domain
import eu.kanade.domain.chapter.interactor.GetAvailableScanlators
import eu.kanade.domain.chapter.interactor.SetReadStatus
import eu.kanade.domain.chapter.interactor.SyncChaptersWithSource
import eu.kanade.domain.download.interactor.DeleteDownload
import eu.kanade.domain.extension.interactor.GetExtensionLanguages
import eu.kanade.domain.extension.interactor.GetExtensionSources
import eu.kanade.domain.extension.interactor.GetExtensionsByType
import eu.kanade.domain.extension.interactor.TrustExtension
import eu.kanade.domain.manga.interactor.GetExcludedScanlators
import eu.kanade.domain.manga.interactor.SetExcludedScanlators
import eu.kanade.domain.manga.interactor.SetMangaViewerFlags
import eu.kanade.domain.manga.interactor.UpdateManga
import eu.kanade.domain.source.interactor.GetEnabledSources
import eu.kanade.domain.source.interactor.GetIncognitoState
import eu.kanade.domain.source.interactor.GetLanguagesWithSources
import eu.kanade.domain.source.interactor.GetSourcesWithFavoriteCount
import eu.kanade.domain.source.interactor.SetMigrateSorting
import eu.kanade.domain.source.interactor.ToggleIncognito
import eu.kanade.domain.source.interactor.ToggleLanguage
import eu.kanade.domain.source.interactor.ToggleSource
import eu.kanade.domain.source.interactor.ToggleSourcePin
import eu.kanade.domain.track.interactor.AddTracks
import eu.kanade.domain.track.interactor.RefreshTracks
import eu.kanade.domain.track.interactor.SyncChapterProgressWithTrack
import eu.kanade.domain.track.interactor.TrackChapter
import mihon.data.extension.repository.ExtensionStoreRepositoryImpl
import mihon.data.extension.service.ExtensionStoreService
import mihon.domain.chapter.interactor.FilterChaptersForDownload
import mihon.domain.extension.interactor.AddExtensionStore
import mihon.domain.extension.interactor.GetExtensionStoreCountAsFlow
import mihon.domain.extension.interactor.GetExtensionStores
import mihon.domain.extension.interactor.RemoveExtensionStore
import mihon.domain.extension.interactor.UpdateExtensionStores
import mihon.domain.extension.repository.ExtensionStoreRepository
import mihon.domain.migration.usecases.MigrateMangaUseCase
import mihon.domain.source.interactor.UpdateMangaFromRemote
import mihon.domain.upcoming.interactor.GetUpcomingManga
import tachiyomi.data.category.CategoryRepositoryImpl
import tachiyomi.data.chapter.ChapterRepositoryImpl
import tachiyomi.data.history.HistoryRepositoryImpl
import tachiyomi.data.manga.MangaRepositoryImpl
import tachiyomi.data.release.ReleaseServiceImpl
import tachiyomi.data.source.SourceRepositoryImpl
import tachiyomi.data.source.StubSourceRepositoryImpl
import tachiyomi.data.track.TrackRepositoryImpl
import tachiyomi.data.updates.UpdatesRepositoryImpl
import tachiyomi.domain.category.interactor.CreateCategoryWithName
import tachiyomi.domain.category.interactor.DeleteCategory
import tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.category.interactor.RenameCategory
import tachiyomi.domain.category.interactor.ReorderCategory
import tachiyomi.domain.category.interactor.ResetCategoryFlags
import tachiyomi.domain.category.interactor.SetDisplayMode
import tachiyomi.domain.category.interactor.SetMangaCategories
import tachiyomi.domain.category.interactor.SetSortModeForCategory
import tachiyomi.domain.category.interactor.UpdateCategory
import tachiyomi.domain.category.repository.CategoryRepository
import tachiyomi.domain.chapter.interactor.GetBookmarkedChaptersByMangaId
import tachiyomi.domain.chapter.interactor.GetChapter
import tachiyomi.domain.chapter.interactor.GetChapterByUrlAndMangaId
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
import tachiyomi.domain.chapter.interactor.SetMangaDefaultChapterFlags
import tachiyomi.domain.chapter.interactor.ShouldUpdateDbChapter
import tachiyomi.domain.chapter.interactor.UpdateChapter
import tachiyomi.domain.chapter.repository.ChapterRepository
import tachiyomi.domain.history.interactor.GetHistory
import tachiyomi.domain.history.interactor.GetNextChapters
import tachiyomi.domain.history.interactor.GetTotalReadDuration
import tachiyomi.domain.history.interactor.RemoveHistory
import tachiyomi.domain.history.interactor.UpsertHistory
import tachiyomi.domain.history.repository.HistoryRepository
import tachiyomi.domain.manga.interactor.FetchInterval
import tachiyomi.domain.manga.interactor.GetDuplicateLibraryManga
import tachiyomi.domain.manga.interactor.GetFavorites
import tachiyomi.domain.manga.interactor.GetLibraryManga
import tachiyomi.domain.manga.interactor.GetManga
import tachiyomi.domain.manga.interactor.GetMangaByUrlAndSourceId
import tachiyomi.domain.manga.interactor.GetMangaWithChapters
import tachiyomi.domain.manga.interactor.NetworkToLocalManga
import tachiyomi.domain.manga.interactor.ResetViewerFlags
import tachiyomi.domain.manga.interactor.SetMangaChapterFlags
import tachiyomi.domain.manga.interactor.UpdateMangaNotes
import tachiyomi.domain.manga.repository.MangaRepository
import tachiyomi.domain.release.interactor.GetApplicationRelease
import tachiyomi.domain.release.service.ReleaseService
import tachiyomi.domain.source.interactor.GetRemoteManga
import tachiyomi.domain.source.interactor.GetSourcesWithNonLibraryManga
import tachiyomi.domain.source.repository.SourceRepository
import tachiyomi.domain.source.repository.StubSourceRepository
import tachiyomi.domain.track.interactor.DeleteTrack
import tachiyomi.domain.track.interactor.GetTracks
import tachiyomi.domain.track.interactor.GetTracksPerManga
import tachiyomi.domain.track.interactor.InsertTrack
import tachiyomi.domain.track.repository.TrackRepository
import tachiyomi.domain.updates.interactor.GetUpdates
import tachiyomi.domain.updates.repository.UpdatesRepository
import uy.kohesive.injekt.api.InjektModule
import uy.kohesive.injekt.api.InjektRegistrar
import uy.kohesive.injekt.api.addFactory
import uy.kohesive.injekt.api.addSingletonFactory
import uy.kohesive.injekt.api.get
class DomainModule : InjektModule {
override fun InjektRegistrar.registerInjectables() {
addSingletonFactory<CategoryRepository> { CategoryRepositoryImpl(get()) }
addFactory { GetCategories(get()) }
addFactory { ResetCategoryFlags(get(), get()) }
addFactory { SetDisplayMode(get()) }
addFactory { SetSortModeForCategory(get(), get()) }
addFactory { CreateCategoryWithName(get(), get()) }
addFactory { RenameCategory(get()) }
addFactory { ReorderCategory(get()) }
addFactory { UpdateCategory(get()) }
addFactory { DeleteCategory(get(), get(), get()) }
addSingletonFactory<MangaRepository> { MangaRepositoryImpl(get()) }
addFactory { GetDuplicateLibraryManga(get()) }
addFactory { GetFavorites(get()) }
addFactory { GetLibraryManga(get()) }
addFactory { GetMangaWithChapters(get(), get()) }
addFactory { GetMangaByUrlAndSourceId(get()) }
addFactory { GetManga(get()) }
addFactory { GetNextChapters(get(), get(), get()) }
addFactory { GetUpcomingManga(get()) }
addFactory { ResetViewerFlags(get()) }
addFactory { SetMangaChapterFlags(get()) }
addFactory { FetchInterval(get()) }
addFactory { SetMangaDefaultChapterFlags(get(), get(), get()) }
addFactory { SetMangaViewerFlags(get()) }
addFactory { NetworkToLocalManga(get()) }
addFactory { UpdateManga(get(), get()) }
addFactory { UpdateMangaNotes(get()) }
addFactory { SetMangaCategories(get()) }
addFactory { GetExcludedScanlators(get()) }
addFactory { SetExcludedScanlators(get()) }
addFactory {
MigrateMangaUseCase(
get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(),
)
}
addSingletonFactory<ReleaseService> { ReleaseServiceImpl(get(), get()) }
addFactory { GetApplicationRelease(get(), get()) }
addSingletonFactory<TrackRepository> { TrackRepositoryImpl(get()) }
addFactory { TrackChapter(get(), get(), get(), get()) }
addFactory { AddTracks(get(), get(), get(), get()) }
addFactory { RefreshTracks(get(), get(), get(), get()) }
addFactory { DeleteTrack(get()) }
addFactory { GetTracksPerManga(get()) }
addFactory { GetTracks(get()) }
addFactory { InsertTrack(get()) }
addFactory { SyncChapterProgressWithTrack(get(), get(), get()) }
addSingletonFactory<ChapterRepository> { ChapterRepositoryImpl(get()) }
addFactory { GetChapter(get()) }
addFactory { GetChaptersByMangaId(get()) }
addFactory { GetBookmarkedChaptersByMangaId(get()) }
addFactory { GetChapterByUrlAndMangaId(get()) }
addFactory { UpdateChapter(get()) }
addFactory { SetReadStatus(get(), get(), get(), get()) }
addFactory { ShouldUpdateDbChapter() }
addFactory { SyncChaptersWithSource(get(), get(), get(), get(), get(), get(), get(), get(), get()) }
addFactory { GetAvailableScanlators(get()) }
addFactory { FilterChaptersForDownload(get(), get(), get()) }
addSingletonFactory<HistoryRepository> { HistoryRepositoryImpl(get()) }
addFactory { GetHistory(get()) }
addFactory { UpsertHistory(get()) }
addFactory { RemoveHistory(get()) }
addFactory { GetTotalReadDuration(get()) }
addFactory { DeleteDownload(get(), get()) }
addFactory { GetExtensionsByType(get(), get()) }
addFactory { GetExtensionSources(get()) }
addFactory { GetExtensionLanguages(get(), get()) }
addSingletonFactory<UpdatesRepository> { UpdatesRepositoryImpl(get()) }
addFactory { GetUpdates(get()) }
addSingletonFactory<SourceRepository> { SourceRepositoryImpl(get(), get()) }
addSingletonFactory<StubSourceRepository> { StubSourceRepositoryImpl(get()) }
addFactory { GetEnabledSources(get(), get()) }
addFactory { GetLanguagesWithSources(get(), get()) }
addFactory { GetRemoteManga(get()) }
addFactory { GetSourcesWithFavoriteCount(get(), get()) }
addFactory { GetSourcesWithNonLibraryManga(get()) }
addFactory { SetMigrateSorting(get()) }
addFactory { ToggleLanguage(get()) }
addFactory { ToggleSource(get()) }
addFactory { ToggleSourcePin(get()) }
addFactory { TrustExtension(get(), get()) }
addSingletonFactory { ExtensionStoreService(get(), get(), get()) }
addSingletonFactory<ExtensionStoreRepository> { ExtensionStoreRepositoryImpl(get(), get()) }
addFactory { AddExtensionStore(get()) }
addFactory { GetExtensionStoreCountAsFlow(get()) }
addFactory { GetExtensionStores(get()) }
addFactory { RemoveExtensionStore(get()) }
addFactory { UpdateExtensionStores(get()) }
addFactory { ToggleIncognito(get()) }
addFactory { GetIncognitoState(get(), get(), get()) }
addFactory { UpdateMangaFromRemote(get(), get(), get(), get(), get(), get(), get()) }
}
}
@@ -2,15 +2,11 @@ package eu.kanade.domain.base
import android.content.Context
import dev.icerock.moko.resources.StringResource
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import eu.kanade.tachiyomi.util.system.GLUtil
import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.i18n.MR
@Inject
@SingleIn(AppScope::class)
class BasePreferences(
val context: Context,
preferenceStore: PreferenceStore,
@@ -37,7 +33,17 @@ class BasePreferences(
PRIVATE(MR.strings.ext_installer_private, false),
}
val highQualityRenderer: Preference<Boolean> = preferenceStore.getBoolean("pref_high_quality_renderer_key", false)
val displayProfile: Preference<String> = preferenceStore.getString("pref_display_profile_key", "")
val hardwareBitmapThreshold: Preference<Int> = preferenceStore.getInt(
"pref_hardware_bitmap_threshold",
GLUtil.SAFE_TEXTURE_LIMIT,
)
val alwaysDecodeLongStripWithSSIV: Preference<Boolean> = preferenceStore.getBoolean(
"pref_always_decode_long_strip_with_ssiv",
false,
)
val installationId: Preference<String> = preferenceStore.getString(Preference.appStateKey("installation_id"), "")
@@ -45,9 +51,4 @@ class BasePreferences(
Preference.appStateKey("donation_campaign_shown"),
false,
)
val skippedAppUpdate: Preference<String> = preferenceStore.getString(
Preference.appStateKey("skipped_app_update"),
"",
)
}
@@ -1,11 +1,9 @@
package eu.kanade.domain.chapter.interactor
import dev.zacsweers.metro.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import tachiyomi.domain.chapter.repository.ChapterRepository
@Inject
class GetAvailableScanlators(
private val repository: ChapterRepository,
) {
@@ -1,8 +1,8 @@
package eu.kanade.domain.chapter.interactor
import dev.zacsweers.metro.Inject
import eu.kanade.domain.download.interactor.DeleteDownload
import logcat.LogPriority
import tachiyomi.core.common.util.lang.withNonCancellableContext
import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.chapter.model.ChapterUpdate
@@ -11,7 +11,6 @@ import tachiyomi.domain.download.service.DownloadPreferences
import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.manga.repository.MangaRepository
@Inject
class SetReadStatus(
private val downloadPreferences: DownloadPreferences,
private val deleteDownload: DeleteDownload,
@@ -20,13 +19,14 @@ class SetReadStatus(
) {
private val mapper = { chapter: Chapter, read: Boolean ->
ChapterUpdate(chapter.id) {
this.read = read
if (!read) lastPageRead = 0
}
ChapterUpdate(
read = read,
lastPageRead = if (!read) 0 else null,
id = chapter.id,
)
}
suspend fun await(read: Boolean, vararg chapters: Chapter): Result {
suspend fun await(read: Boolean, vararg chapters: Chapter): Result = withNonCancellableContext {
val chaptersToUpdate = chapters.filter {
when (read) {
true -> !it.read
@@ -34,7 +34,7 @@ class SetReadStatus(
}
}
if (chaptersToUpdate.isEmpty()) {
return Result.NoChapters
return@withNonCancellableContext Result.NoChapters
}
try {
@@ -43,14 +43,11 @@ class SetReadStatus(
)
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
return Result.InternalError(e)
return@withNonCancellableContext Result.InternalError(e)
}
if (read && downloadPreferences.removeAfterMarkedAsRead.get()) {
chaptersToUpdate
// Chapters here still carry their pre-update read state; downstream
// deletion filtering (excluded categories) relies on the new state.
.map { it.copy(read = true) }
.groupBy { it.mangaId }
.forEach { (mangaId, chapters) ->
deleteDownload.awaitAll(
@@ -60,16 +57,17 @@ class SetReadStatus(
}
}
return Result.Success
Result.Success
}
suspend fun await(mangaId: Long, read: Boolean): Result =
suspend fun await(mangaId: Long, read: Boolean): Result = withNonCancellableContext {
await(
read = read,
chapters = chapterRepository
.getChapterByMangaId(mangaId)
.toTypedArray(),
)
}
suspend fun await(manga: Manga, read: Boolean) =
await(manga.id, read)
@@ -1,6 +1,5 @@
package eu.kanade.domain.chapter.interactor
import dev.zacsweers.metro.Inject
import eu.kanade.domain.chapter.model.copyFromSChapter
import eu.kanade.domain.chapter.model.toSChapter
import eu.kanade.domain.manga.interactor.GetExcludedScanlators
@@ -11,32 +10,30 @@ import eu.kanade.tachiyomi.data.download.DownloadProvider
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.online.HttpSource
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import tachiyomi.data.chapter.ChapterSanitizer
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
import tachiyomi.domain.chapter.interactor.ShouldUpdateDbChapter
import tachiyomi.domain.chapter.interactor.UpdateChapter
import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.chapter.model.ChapterRemoteUpdate
import tachiyomi.domain.chapter.model.NoChaptersException
import tachiyomi.domain.chapter.model.toChapterUpdate
import tachiyomi.domain.chapter.repository.ChapterRepository
import tachiyomi.domain.chapter.service.ChapterRecognition
import tachiyomi.domain.library.service.LibraryPreferences
import tachiyomi.domain.manga.model.Manga
import tachiyomi.source.local.isLocal
import java.lang.Long.max
import java.time.ZonedDateTime
import java.util.TreeSet
import kotlin.time.Clock
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Instant
@Inject
class SyncChaptersWithSource(
private val downloadManager: DownloadManager,
private val downloadProvider: DownloadProvider,
private val chapterRepository: ChapterRepository,
private val shouldUpdateDbChapter: ShouldUpdateDbChapter,
private val updateManga: UpdateManga,
private val updateChapter: UpdateChapter,
private val getChaptersByMangaId: GetChaptersByMangaId,
private val getExcludedScanlators: GetExcludedScanlators,
private val libraryPreferences: LibraryPreferences,
) {
@@ -54,15 +51,14 @@ class SyncChaptersWithSource(
manga: Manga,
source: Source,
manualFetch: Boolean = false,
fetchWindow: ClosedRange<Instant>? = null,
fetchWindow: Pair<Long, Long> = Pair(0, 0),
): List<Chapter> {
if (rawSourceChapters.isEmpty() && !source.isLocal()) {
throw NoChaptersException()
}
val timeZone = TimeZone.currentSystemDefault()
val now = Clock.System.now().toLocalDateTime(timeZone)
val nowInstant = now.toInstant(timeZone)
val now = ZonedDateTime.now()
val nowMillis = now.toInstant().toEpochMilli()
val sourceChapters = rawSourceChapters
.distinctBy { it.url }
@@ -73,46 +69,43 @@ class SyncChaptersWithSource(
.copy(mangaId = manga.id, sourceOrder = i.toLong())
}
val dbChapters = chapterRepository.getChapterByMangaId(manga.id)
val dbChaptersByUrl = dbChapters.associateBy { it.url }
val dbChapters = getChaptersByMangaId.await(manga.id)
val newChapters = mutableListOf<Chapter>()
val updatedChapters = mutableListOf<ChapterRemoteUpdate>()
val sourceUrls = mutableSetOf<String>()
val updatedChapters = mutableListOf<Chapter>()
val removedChapters = dbChapters.filterNot { dbChapter ->
sourceChapters.any { sourceChapter ->
dbChapter.url == sourceChapter.url
}
}
// Used to not set upload date of older chapters
// to a higher value than newer chapters
var maxSeenUploadDate: Instant? = null
var maxSeenUploadDate = 0L
// Update metadata from source if necessary.
val preparedChapters = if (source is HttpSource) {
val sManga = manga.toSManga()
sourceChapters.map { chapter ->
for (sourceChapter in sourceChapters) {
var chapter = sourceChapter
// Update metadata from source if necessary.
if (source is HttpSource) {
val sChapter = chapter.toSChapter()
@Suppress("DEPRECATION")
source.prepareNewChapter(sChapter, sManga)
chapter.copyFromSChapter(sChapter)
}
} else {
sourceChapters
}
// Recognize chapter numbers, from the whole list since names alone don't always tell.
val recognizedChapters = preparedChapters
.zip(ChapterRecognition.parseChapterNumbers(manga.title, preparedChapters)) { chapter, numbering ->
chapter.copy(chapterNumber = numbering.chapter?.let(ChapterRecognition::toDouble) ?: -1.0)
source.prepareNewChapter(sChapter, manga.toSManga())
chapter = chapter.copyFromSChapter(sChapter)
}
for (chapter in recognizedChapters) {
if (!sourceUrls.add(chapter.url)) continue
// Recognize chapter number for the chapter.
val chapterNumber = ChapterRecognition.parseChapterNumber(manga.title, chapter.name, chapter.chapterNumber)
chapter = chapter.copy(chapterNumber = chapterNumber)
val dbChapter = dbChaptersByUrl[chapter.url]
val dbChapter = dbChapters.find { it.url == chapter.url }
if (dbChapter == null) {
val toAddChapter = if (chapter.dateUpload == null) {
chapter.copy(dateUpload = maxSeenUploadDate ?: nowInstant)
val toAddChapter = if (chapter.dateUpload == 0L) {
val altDateUpload = if (maxSeenUploadDate == 0L) nowMillis else maxSeenUploadDate
chapter.copy(dateUpload = altDateUpload)
} else {
maxSeenUploadDate = listOfNotNull(maxSeenUploadDate, chapter.dateUpload).maxOrNull()
maxSeenUploadDate = max(maxSeenUploadDate, sourceChapter.dateUpload)
chapter
}
newChapters.add(toAddChapter)
@@ -131,36 +124,27 @@ class SyncChaptersWithSource(
downloadManager.renameChapter(source, manga, dbChapter, chapter)
}
updatedChapters.add(
ChapterRemoteUpdate(
id = dbChapter.id,
name = chapter.name,
chapterNumber = chapter.chapterNumber,
scanlator = chapter.scanlator,
sourceOrder = chapter.sourceOrder,
dateUpload = chapter.dateUpload,
memo = chapter.memo,
),
var toChangeChapter = dbChapter.copy(
name = chapter.name,
chapterNumber = chapter.chapterNumber,
scanlator = chapter.scanlator,
sourceOrder = chapter.sourceOrder,
memo = chapter.memo,
)
if (chapter.dateUpload != 0L) {
toChangeChapter = toChangeChapter.copy(dateUpload = chapter.dateUpload)
}
updatedChapters.add(toChangeChapter)
}
}
}
val removedChapters = dbChapters.filterNot { it.url in sourceUrls }
// The source no longer lists these, so their queued downloads could only fail
removedChapters.mapNotNull { downloadManager.getQueuedDownloadOrNull(it.id) }
.takeIf { it.isNotEmpty() }
?.let { downloadManager.cancelQueuedDownloads(it) }
// Return if there's nothing to add, delete, or update to avoid unnecessary db transactions.
if (newChapters.isEmpty() && removedChapters.isEmpty() && updatedChapters.isEmpty()) {
val isNextUpdateBeforeWindow = fetchWindow != null &&
manga.nextUpdate.let { it == null || it < fetchWindow.start }
if (manualFetch || manga.fetchInterval == 0 || isNextUpdateBeforeWindow) {
if (manualFetch || manga.fetchInterval == 0 || manga.nextUpdate < fetchWindow.first) {
updateManga.awaitUpdateFetchInterval(
manga,
timeZone,
now,
fetchWindow,
)
@@ -195,8 +179,8 @@ class SyncChaptersWithSource(
// Date fetch is set in such a way that the upper ones will have bigger value than the lower ones
// Sources MUST return the chapters from most to less recent, which is common.
var itemCount = newChapters.size
val toAdd = newChapters.map { toAddItem ->
var chapter = toAddItem.copy(dateFetch = nowInstant + (itemCount--).milliseconds)
var updatedToAdd = newChapters.map { toAddItem ->
var chapter = toAddItem.copy(dateFetch = nowMillis + itemCount--)
if (chapter.chapterNumber in readChapterNumbers && markDuplicateAsRead) {
changedOrDuplicateReadUrls.add(chapter.url)
@@ -220,25 +204,27 @@ class SyncChaptersWithSource(
chapter
}
val added = chapterRepository.updateFromRemote(
removedIds = removedChapters.map { it.id },
added = toAdd,
updated = updatedChapters,
)
if (removedChapters.isNotEmpty()) {
val toDeleteIds = removedChapters.map { it.id }
chapterRepository.removeChaptersWithIds(toDeleteIds)
}
val excludedScanlators = getExcludedScanlators.await(manga.id).toHashSet()
if (updatedToAdd.isNotEmpty()) {
updatedToAdd = chapterRepository.addAll(updatedToAdd)
}
// A first fetch lists the back catalogue, and chapters coming back under a deleted number were out already
val foundRelease = dbChapters.isNotEmpty() &&
toAdd.any { chapter ->
chapter.scanlator !in excludedScanlators &&
!(chapter.isRecognizedNumber && chapter.chapterNumber in deletedChapterNumbers)
}
if (updatedChapters.isNotEmpty()) {
val chapterUpdates = updatedChapters.map { it.toChapterUpdate() }
updateChapter.awaitAll(chapterUpdates)
}
updateManga.awaitUpdateFetchInterval(manga, now, fetchWindow)
// Set this manga as updated since chapters were changed
// Note that last_update actually represents last time the chapter list changed at all
updateManga.awaitUpdateFetchIntervalAndLastUpdate(manga, timeZone, now, fetchWindow, foundRelease)
updateManga.awaitUpdateLastUpdate(manga.id)
return added.filterNot { it.url in changedOrDuplicateReadUrls || it.scanlator in excludedScanlators }
val excludedScanlators = getExcludedScanlators.await(manga.id).toHashSet()
return updatedToAdd.filterNot { it.url in changedOrDuplicateReadUrls || it.scanlator in excludedScanlators }
}
}
@@ -1,15 +1,16 @@
package eu.kanade.domain.chapter.model
import eu.kanade.tachiyomi.data.database.models.ChapterImpl
import eu.kanade.tachiyomi.source.model.SChapter
import mihon.core.common.extensions.toEpochMillisOrZero
import mihon.core.common.extensions.toInstantOrNull
import tachiyomi.domain.chapter.model.Chapter
import eu.kanade.tachiyomi.data.database.models.Chapter as DbChapter
// TODO: Remove when all deps are migrated
fun Chapter.toSChapter(): SChapter {
return SChapter.create().also {
it.url = url
it.name = name
it.date_upload = dateUpload.toEpochMillisOrZero()
it.date_upload = dateUpload
it.chapter_number = chapterNumber.toFloat()
it.scanlator = scanlator
it.memo = memo
@@ -20,9 +21,25 @@ fun Chapter.copyFromSChapter(sChapter: SChapter): Chapter {
return this.copy(
name = sChapter.name,
url = sChapter.url,
dateUpload = sChapter.date_upload.toInstantOrNull(),
dateUpload = sChapter.date_upload,
chapterNumber = sChapter.chapter_number.toDouble(),
scanlator = sChapter.scanlator?.ifBlank { null }?.trim(),
memo = sChapter.memo,
)
}
fun Chapter.toDbChapter(): DbChapter = ChapterImpl().also {
it.id = id
it.manga_id = mangaId
it.url = url
it.name = name
it.scanlator = scanlator
it.read = read
it.bookmark = bookmark
it.last_page_read = lastPageRead.toInt()
it.date_fetch = dateFetch
it.date_upload = dateUpload
it.chapter_number = chapterNumber.toFloat()
it.source_order = sourceOrder.toInt()
it.memo = memo
}
@@ -1,18 +1,17 @@
package eu.kanade.domain.download.interactor
import dev.zacsweers.metro.Inject
import eu.kanade.tachiyomi.data.download.DownloadManager
import tachiyomi.core.common.util.lang.withNonCancellableContext
import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.source.service.SourceManager
@Inject
class DeleteDownload(
private val sourceManager: SourceManager,
private val downloadManager: DownloadManager,
) {
suspend fun awaitAll(manga: Manga, vararg chapters: Chapter) {
suspend fun awaitAll(manga: Manga, vararg chapters: Chapter) = withNonCancellableContext {
sourceManager.get(manga.source)?.let { source ->
downloadManager.deleteChapters(chapters.toList(), manga, source)
}
@@ -1,13 +1,11 @@
package eu.kanade.domain.extension.interactor
import dev.zacsweers.metro.Inject
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.util.system.LocaleHelper
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
@Inject
class GetExtensionLanguages(
private val preferences: SourcePreferences,
private val extensionManager: ExtensionManager,
@@ -1,18 +1,16 @@
package eu.kanade.domain.extension.interactor
import dev.zacsweers.metro.Inject
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.source.Source
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@Inject
class GetExtensionSources(
private val preferences: SourcePreferences,
) {
fun subscribe(extension: Extension.Loaded): Flow<List<ExtensionSourceItem>> {
fun subscribe(extension: Extension.Installed): Flow<List<ExtensionSourceItem>> {
val isMultiSource = extension.sources.size > 1
val isMultiLangSingleSource =
isMultiSource && extension.sources.map { it.name }.distinct().size == 1
@@ -1,6 +1,5 @@
package eu.kanade.domain.extension.interactor
import dev.zacsweers.metro.Inject
import eu.kanade.domain.extension.model.Extensions
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.tachiyomi.extension.ExtensionManager
@@ -8,33 +7,36 @@ import eu.kanade.tachiyomi.extension.model.Extension
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
@Inject
class GetExtensionsByType(
private val preferences: SourcePreferences,
private val extensionManager: ExtensionManager,
) {
fun subscribe(): Flow<Extensions> {
val enabledContentWarnings = preferences.enabledContentWarnings.get()
val showNsfwSources = preferences.showNsfwSource.get()
return combine(
preferences.enabledLanguages.changes(),
extensionManager.loadedExtensionsFlow,
extensionManager.notLoadedExtensionsFlow,
extensionManager.installedExtensionsFlow,
extensionManager.untrustedExtensionsFlow,
extensionManager.availableExtensionsFlow,
) { enabledLanguages, _loaded, _notLoaded, _available ->
val byName = compareBy<Extension, String>(String.CASE_INSENSITIVE_ORDER) { it.name }
) { enabledLanguages, _installed, _untrusted, _available ->
val (updates, installed) = _installed
.filter { (showNsfwSources || !it.isNsfw) }
.sortedWith(
compareBy<Extension.Installed> { !it.isObsolete }
.thenBy(String.CASE_INSENSITIVE_ORDER) { it.name },
)
.partition { it.hasUpdate }
val (loadedUpdates, loaded) = _loaded.partition { it.hasUpdate }
val (notLoadedUpdates, notLoaded) = _notLoaded.partition { it.hasUpdate }
val untrusted = _untrusted
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
val updates = (loadedUpdates + notLoadedUpdates).sortedWith(byName)
val installedSignatures = (_loaded + _notLoaded).groupBy({ it.pkgName }, { it.signatures })
val available = _available
.filter { extension ->
installedSignatures[extension.pkgName].orEmpty().none { extension.store.signingKey in it } &&
extension.contentWarning in enabledContentWarnings
_installed.none { it.pkgName == extension.pkgName } &&
_untrusted.none { it.pkgName == extension.pkgName } &&
(showNsfwSources || !extension.isNsfw)
}
.flatMap { ext ->
ext.sources.filter { it.lang in enabledLanguages }
@@ -47,17 +49,9 @@ class GetExtensionsByType(
)
}
}
.sortedWith(
compareBy<Extension.Available, String>(String.CASE_INSENSITIVE_ORDER) { it.name }
.thenBy { it.store.signingKey },
)
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
Extensions(
updates = updates,
loaded = loaded.sortedWith(compareBy<Extension.Loaded> { !it.isObsolete }.then(byName)),
available = available,
notLoaded = notLoaded.sortedWith(byName),
)
Extensions(updates, installed, available, untrusted)
}
}
}
@@ -2,25 +2,17 @@ package eu.kanade.domain.extension.interactor
import android.content.pm.PackageInfo
import androidx.core.content.pm.PackageInfoCompat
import dev.zacsweers.metro.Inject
import eu.kanade.domain.source.service.SourcePreferences
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import mihon.domain.extension.model.ExtensionStore
import mihon.domain.extension.repository.ExtensionStoreRepository
import tachiyomi.core.common.preference.getAndSet
@Inject
class TrustExtension(
private val repository: ExtensionStoreRepository,
private val preferences: SourcePreferences,
) {
fun isTrusted(pkgInfo: PackageInfo, fingerprints: List<String>, stores: List<ExtensionStore>): Boolean {
val trustedFingerprints = stores.mapTo(HashSet()) { it.signingKey }
suspend fun isTrusted(pkgInfo: PackageInfo, fingerprints: List<String>): Boolean {
val trustedFingerprints = repository.getAll().map { it.signingKey }.toHashSet()
val key = "${pkgInfo.packageName}:${PackageInfoCompat.getLongVersionCode(pkgInfo)}:${fingerprints.last()}"
return trustedFingerprints.any { fingerprints.contains(it) } || key in preferences.trustedExtensions.get()
}
@@ -37,23 +29,4 @@ class TrustExtension(
fun revokeAll() {
preferences.trustedExtensions.delete()
}
/**
* Emits whenever what counts as trusted changes, either because a store was added or removed or
* because an extension was trusted or had its trust revoked. Both sources replay their current
* value, which is dropped.
*/
fun changes(): Flow<Unit> {
return merge(
// Stores are rewritten whenever their index is refreshed, so only their keys matter here
repository.getAllAsFlow()
.map { stores -> stores.mapTo(HashSet()) { it.signingKey } }
.distinctUntilChanged()
.drop(1),
preferences.trustedExtensions.changes()
.distinctUntilChanged()
.drop(1),
)
.map {}
}
}
@@ -4,7 +4,7 @@ import eu.kanade.tachiyomi.extension.model.Extension
data class Extensions(
val updates: List<Extension.Installed>,
val loaded: List<Extension.Loaded>,
val installed: List<Extension.Installed>,
val available: List<Extension.Available>,
val notLoaded: List<Extension.NotLoaded>,
val untrusted: List<Extension.Untrusted>,
)
@@ -1,19 +1,26 @@
package eu.kanade.domain.manga.interactor
import dev.zacsweers.metro.Inject
import app.cash.sqldelight.async.coroutines.awaitAsList
import kotlinx.coroutines.flow.Flow
import tachiyomi.domain.manga.repository.MangaRepository
import kotlinx.coroutines.flow.map
import tachiyomi.data.Database
import tachiyomi.data.subscribeToList
@Inject
class GetExcludedScanlators(
private val mangaRepository: MangaRepository,
private val database: Database,
) {
suspend fun await(mangaId: Long): Set<String> {
return mangaRepository.getExcludedScanlators(mangaId)
return database.excluded_scanlatorsQueries
.getExcludedScanlatorsByMangaId(mangaId)
.awaitAsList()
.toSet()
}
fun subscribe(mangaId: Long): Flow<Set<String>> {
return mangaRepository.getExcludedScanlatorsAsFlow(mangaId)
return database.excluded_scanlatorsQueries
.getExcludedScanlatorsByMangaId(mangaId)
.subscribeToList()
.map { it.toSet() }
}
}
@@ -1,14 +1,24 @@
package eu.kanade.domain.manga.interactor
import dev.zacsweers.metro.Inject
import tachiyomi.domain.manga.repository.MangaRepository
import app.cash.sqldelight.async.coroutines.awaitAsList
import tachiyomi.data.Database
@Inject
class SetExcludedScanlators(
private val mangaRepository: MangaRepository,
private val database: Database,
) {
suspend fun await(mangaId: Long, excludedScanlators: Set<String>) {
mangaRepository.setExcludedScanlators(mangaId, excludedScanlators)
database.transaction {
val currentExcluded = database.excluded_scanlatorsQueries
.getExcludedScanlatorsByMangaId(mangaId)
.awaitAsList()
.toSet()
val toAdd = excludedScanlators.minus(currentExcluded)
for (scanlator in toAdd) {
database.excluded_scanlatorsQueries.insert(mangaId, scanlator)
}
val toRemove = currentExcluded.minus(excludedScanlators)
database.excluded_scanlatorsQueries.remove(mangaId, toRemove)
}
}
}
@@ -1,12 +1,10 @@
package eu.kanade.domain.manga.interactor
import dev.zacsweers.metro.Inject
import eu.kanade.tachiyomi.ui.reader.setting.ReaderOrientation
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode
import tachiyomi.domain.manga.model.MangaUpdate
import tachiyomi.domain.manga.repository.MangaRepository
@Inject
class SetMangaViewerFlags(
private val mangaRepository: MangaRepository,
) {
@@ -14,18 +12,20 @@ class SetMangaViewerFlags(
suspend fun awaitSetReadingMode(id: Long, flag: Long) {
val manga = mangaRepository.getMangaById(id)
mangaRepository.update(
MangaUpdate(id) {
viewerFlags = manga.viewerFlags.setFlag(flag, ReadingMode.MASK.toLong())
},
MangaUpdate(
id = id,
viewerFlags = manga.viewerFlags.setFlag(flag, ReadingMode.MASK.toLong()),
),
)
}
suspend fun awaitSetOrientation(id: Long, flag: Long) {
val manga = mangaRepository.getMangaById(id)
mangaRepository.update(
MangaUpdate(id) {
viewerFlags = manga.viewerFlags.setFlag(flag, ReaderOrientation.MASK.toLong())
},
MangaUpdate(
id = id,
viewerFlags = manga.viewerFlags.setFlag(flag, ReaderOrientation.MASK.toLong()),
),
)
}
@@ -1,17 +1,12 @@
package eu.kanade.domain.manga.interactor
import dev.zacsweers.metro.Inject
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import tachiyomi.domain.manga.interactor.FetchInterval
import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.manga.model.MangaUpdate
import tachiyomi.domain.manga.repository.MangaRepository
import kotlin.time.Clock
import kotlin.time.Instant
import java.time.Instant
import java.time.ZonedDateTime
@Inject
class UpdateManga(
private val mangaRepository: MangaRepository,
private val fetchInterval: FetchInterval,
@@ -27,45 +22,29 @@ class UpdateManga(
suspend fun awaitUpdateFetchInterval(
manga: Manga,
timeZone: TimeZone = TimeZone.currentSystemDefault(),
dateTime: LocalDateTime = Clock.System.now().toLocalDateTime(timeZone),
window: ClosedRange<Instant>? = fetchInterval.getWindow(dateTime.date, timeZone),
dateTime: ZonedDateTime = ZonedDateTime.now(),
window: Pair<Long, Long> = fetchInterval.getWindow(dateTime),
): Boolean {
return mangaRepository.update(
fetchInterval.withFetchInterval(manga, dateTime, timeZone, window),
fetchInterval.toMangaUpdate(manga, dateTime, window),
)
}
/**
* Updates the fetch interval and marks the chapter list as changed in a single write.
*/
suspend fun awaitUpdateFetchIntervalAndLastUpdate(
manga: Manga,
timeZone: TimeZone,
dateTime: LocalDateTime,
window: ClosedRange<Instant>?,
foundRelease: Boolean,
): Boolean {
return mangaRepository.update(
fetchInterval.withFetchInterval(manga, dateTime, timeZone, window, foundRelease).apply {
lastUpdate = Clock.System.now()
},
)
suspend fun awaitUpdateLastUpdate(mangaId: Long): Boolean {
return mangaRepository.update(MangaUpdate(id = mangaId, lastUpdate = Instant.now().toEpochMilli()))
}
suspend fun awaitUpdateCoverLastModified(mangaId: Long): Boolean {
return mangaRepository.update(
MangaUpdate(mangaId) {
coverLastModified = Clock.System.now()
},
)
return mangaRepository.update(MangaUpdate(id = mangaId, coverLastModified = Instant.now().toEpochMilli()))
}
suspend fun awaitUpdateFavorite(mangaId: Long, favorite: Boolean): Boolean {
val update = when (favorite) {
true -> MangaUpdate(mangaId) { favoriteAt = Clock.System.now() }
false -> MangaUpdate(mangaId) { favoriteAt = null }
val dateAdded = when (favorite) {
true -> Instant.now().toEpochMilli()
false -> 0
}
return mangaRepository.update(update)
return mangaRepository.update(
MangaUpdate(id = mangaId, favorite = favorite, dateAdded = dateAdded),
)
}
}
@@ -1,14 +1,10 @@
package eu.kanade.domain.manga.model
import android.content.Context
import eu.kanade.domain.base.BasePreferences
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.ui.reader.setting.ReaderOrientation
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode
import kotlinx.datetime.TimeZone
import kotlinx.datetime.number
import kotlinx.datetime.toLocalDateTime
import mihon.app.di.appGraph
import tachiyomi.core.common.preference.TriState
import tachiyomi.core.metadata.comicinfo.ComicInfo
import tachiyomi.core.metadata.comicinfo.ComicInfoPublishingStatus
@@ -26,7 +22,7 @@ val Manga.readerOrientation: Long
val Manga.downloadedFilter: TriState
get() {
if (Injekt.get<Context>().appGraph.basePreferences.downloadedOnly.get()) return TriState.ENABLED_IS
if (Injekt.get<BasePreferences>().downloadedOnly.get()) return TriState.ENABLED_IS
return when (downloadedFilterRaw) {
Manga.CHAPTER_SHOW_DOWNLOADED -> TriState.ENABLED_IS
Manga.CHAPTER_SHOW_NOT_DOWNLOADED -> TriState.ENABLED_NOT
@@ -75,8 +71,8 @@ fun Manga.copyFrom(other: SManga): Manga {
)
}
fun Manga.hasCustomCover(coverCache: CoverCache = Injekt.get<Context>().appGraph.coverCache): Boolean {
return coverCache.hasCustomCover(id)
fun Manga.hasCustomCover(coverCache: CoverCache = Injekt.get()): Boolean {
return coverCache.getCustomCoverFile(id).exists()
}
/**
@@ -88,37 +84,30 @@ fun getComicInfo(
urls: List<String>,
categories: List<String>?,
sourceName: String,
): ComicInfo {
val date = chapter.dateUpload?.toLocalDateTime(TimeZone.currentSystemDefault())
return ComicInfo(
title = ComicInfo.Title(chapter.name),
series = ComicInfo.Series(manga.title),
number = chapter.chapterNumber.takeIf { it >= 0 }?.let {
if ((it.rem(1) == 0.0)) {
ComicInfo.Number(it.toInt().toString())
} else {
ComicInfo.Number(it.toString())
}
},
web = ComicInfo.Web(urls.joinToString(" ")),
summary = manga.description?.let { ComicInfo.Summary(it) },
writer = manga.author?.let { ComicInfo.Writer(it) },
penciller = manga.artist?.let { ComicInfo.Penciller(it) },
translator = chapter.scanlator?.let { ComicInfo.Translator(it) },
genre = manga.genre?.let { ComicInfo.Genre(it.joinToString()) },
publishingStatus = ComicInfo.PublishingStatusTachiyomi(
ComicInfoPublishingStatus.toComicInfoValue(manga.status),
),
categories = categories?.let { ComicInfo.CategoriesTachiyomi(it.joinToString()) },
source = ComicInfo.SourceMihon(sourceName),
inker = null,
colorist = null,
letterer = null,
coverArtist = null,
tags = null,
year = date?.year?.let { ComicInfo.Year(it) },
month = date?.month?.number?.let { ComicInfo.Month(it) },
day = date?.day?.let { ComicInfo.Day(it) },
)
}
) = ComicInfo(
title = ComicInfo.Title(chapter.name),
series = ComicInfo.Series(manga.title),
number = chapter.chapterNumber.takeIf { it >= 0 }?.let {
if ((it.rem(1) == 0.0)) {
ComicInfo.Number(it.toInt().toString())
} else {
ComicInfo.Number(it.toString())
}
},
web = ComicInfo.Web(urls.joinToString(" ")),
summary = manga.description?.let { ComicInfo.Summary(it) },
writer = manga.author?.let { ComicInfo.Writer(it) },
penciller = manga.artist?.let { ComicInfo.Penciller(it) },
translator = chapter.scanlator?.let { ComicInfo.Translator(it) },
genre = manga.genre?.let { ComicInfo.Genre(it.joinToString()) },
publishingStatus = ComicInfo.PublishingStatusTachiyomi(
ComicInfoPublishingStatus.toComicInfoValue(manga.status),
),
categories = categories?.let { ComicInfo.CategoriesTachiyomi(it.joinToString()) },
source = ComicInfo.SourceMihon(sourceName),
inker = null,
colorist = null,
letterer = null,
coverArtist = null,
tags = null,
)
@@ -1,6 +1,5 @@
package eu.kanade.domain.source.interactor
import dev.zacsweers.metro.Inject
import eu.kanade.domain.source.service.SourcePreferences
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
@@ -11,7 +10,6 @@ import tachiyomi.domain.source.model.Source
import tachiyomi.domain.source.repository.SourceRepository
import tachiyomi.source.local.isLocal
@Inject
class GetEnabledSources(
private val repository: SourceRepository,
private val preferences: SourcePreferences,
@@ -1,6 +1,5 @@
package eu.kanade.domain.source.interactor
import dev.zacsweers.metro.Inject
import eu.kanade.domain.base.BasePreferences
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.tachiyomi.extension.ExtensionManager
@@ -8,13 +7,12 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
@Inject
class GetIncognitoState(
private val basePreferences: BasePreferences,
private val sourcePreferences: SourcePreferences,
private val extensionManager: ExtensionManager,
) {
suspend fun await(sourceId: Long?): Boolean {
fun await(sourceId: Long?): Boolean {
if (basePreferences.incognitoMode.get()) return true
if (sourceId == null) return false
val extensionPackage = extensionManager.getExtensionPackage(sourceId) ?: return false
@@ -1,6 +1,5 @@
package eu.kanade.domain.source.interactor
import dev.zacsweers.metro.Inject
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.tachiyomi.util.system.LocaleHelper
import kotlinx.coroutines.flow.Flow
@@ -9,7 +8,6 @@ import tachiyomi.domain.source.model.Source
import tachiyomi.domain.source.repository.SourceRepository
import java.util.SortedMap
@Inject
class GetLanguagesWithSources(
private val repository: SourceRepository,
private val preferences: SourcePreferences,
@@ -1,6 +1,5 @@
package eu.kanade.domain.source.interactor
import dev.zacsweers.metro.Inject
import eu.kanade.domain.source.service.SourcePreferences
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
@@ -10,7 +9,6 @@ import tachiyomi.domain.source.repository.SourceRepository
import tachiyomi.source.local.isLocal
import java.util.Collections
@Inject
class GetSourcesWithFavoriteCount(
private val repository: SourceRepository,
private val preferences: SourcePreferences,
@@ -1,9 +1,7 @@
package eu.kanade.domain.source.interactor
import dev.zacsweers.metro.Inject
import eu.kanade.domain.source.service.SourcePreferences
@Inject
class SetMigrateSorting(
private val preferences: SourcePreferences,
) {
@@ -1,10 +1,8 @@
package eu.kanade.domain.source.interactor
import dev.zacsweers.metro.Inject
import eu.kanade.domain.source.service.SourcePreferences
import tachiyomi.core.common.preference.getAndSet
@Inject
class ToggleIncognito(
private val preferences: SourcePreferences,
) {
@@ -1,10 +1,8 @@
package eu.kanade.domain.source.interactor
import dev.zacsweers.metro.Inject
import eu.kanade.domain.source.service.SourcePreferences
import tachiyomi.core.common.preference.getAndSet
@Inject
class ToggleLanguage(
val preferences: SourcePreferences,
) {
@@ -1,11 +1,9 @@
package eu.kanade.domain.source.interactor
import dev.zacsweers.metro.Inject
import eu.kanade.domain.source.service.SourcePreferences
import tachiyomi.core.common.preference.getAndSet
import tachiyomi.domain.source.model.Source
@Inject
class ToggleSource(
private val preferences: SourcePreferences,
) {
@@ -1,11 +1,9 @@
package eu.kanade.domain.source.interactor
import dev.zacsweers.metro.Inject
import eu.kanade.domain.source.service.SourcePreferences
import tachiyomi.core.common.preference.getAndSet
import tachiyomi.domain.source.model.Source
@Inject
class ToggleSourcePin(
private val preferences: SourcePreferences,
) {
@@ -0,0 +1,16 @@
package eu.kanade.domain.source.model
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.core.graphics.drawable.toBitmap
import eu.kanade.tachiyomi.extension.ExtensionManager
import tachiyomi.domain.source.model.Source
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
val Source.icon: ImageBitmap?
get() {
return Injekt.get<ExtensionManager>().getAppIconForSource(id)
?.toBitmap()
?.asImageBitmap()
}
@@ -1,21 +1,14 @@
package eu.kanade.domain.source.service
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import eu.kanade.domain.source.interactor.SetMigrateSorting
import eu.kanade.tachiyomi.util.system.LocaleHelper
import mihon.domain.extension.model.ContentWarning
import mihon.domain.migration.models.MigrationFlag
import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.core.common.preference.getEnum
import tachiyomi.core.common.preference.getEnumSet
import tachiyomi.core.common.preference.getLongArray
import tachiyomi.domain.library.model.LibraryDisplayMode
@Inject
@SingleIn(AppScope::class)
class SourcePreferences(
preferenceStore: PreferenceStore,
) {
@@ -43,15 +36,7 @@ class SourcePreferences(
-1,
)
val enabledContentWarnings: Preference<Set<ContentWarning>> = preferenceStore.getEnumSet(
"enabled_content_warnings",
setOf(ContentWarning.SAFE, ContentWarning.MIXED, ContentWarning.NSFW),
)
val applyContentWarningsToInstalled: Preference<Boolean> = preferenceStore.getBoolean(
"apply_content_warnings_to_installed",
true,
)
val showNsfwSource: Preference<Boolean> = preferenceStore.getBoolean("show_nsfw_source", true)
val migrationSortingMode: Preference<SetMigrateSorting.Mode> = preferenceStore.getEnum(
"pref_migration_sorting",
@@ -1,6 +1,5 @@
package eu.kanade.domain.track.interactor
import dev.zacsweers.metro.Inject
import eu.kanade.domain.track.model.toDbTrack
import eu.kanade.domain.track.model.toDomainTrack
import eu.kanade.tachiyomi.data.database.models.Track
@@ -8,92 +7,101 @@ import eu.kanade.tachiyomi.data.track.EnhancedTracker
import eu.kanade.tachiyomi.data.track.Tracker
import eu.kanade.tachiyomi.data.track.TrackerManager
import eu.kanade.tachiyomi.source.Source
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import eu.kanade.tachiyomi.util.lang.convertEpochMillisZone
import logcat.LogPriority
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.lang.withNonCancellableContext
import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
import tachiyomi.domain.history.interactor.GetHistory
import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.track.interactor.UpsertTrack
import kotlin.time.Instant
import tachiyomi.domain.track.interactor.InsertTrack
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.time.ZoneOffset
@Inject
class AddTracks(
private val upsertTrack: UpsertTrack,
private val insertTrack: InsertTrack,
private val syncChapterProgressWithTrack: SyncChapterProgressWithTrack,
private val getChaptersByMangaId: GetChaptersByMangaId,
private val trackerManager: TrackerManager,
private val getHistory: GetHistory,
) {
// TODO: update all trackers based on common data
suspend fun bind(tracker: Tracker, item: Track, mangaId: Long) = withContext(Dispatchers.IO) {
val allChapters = getChaptersByMangaId.await(mangaId)
val hasReadChapters = allChapters.any { it.read }
tracker.bind(item, hasReadChapters)
suspend fun bind(tracker: Tracker, item: Track, mangaId: Long) = withNonCancellableContext {
withIOContext {
val allChapters = getChaptersByMangaId.await(mangaId)
val hasReadChapters = allChapters.any { it.read }
tracker.bind(item, hasReadChapters)
var track = item.toDomainTrack(idRequired = false) ?: return@withContext
var track = item.toDomainTrack(idRequired = false) ?: return@withIOContext
upsertTrack.await(track)
insertTrack.await(track)
// TODO: merge into [SyncChapterProgressWithTrack]?
// Update chapter progress if newer chapters marked read locally
if (hasReadChapters) {
val latestLocalReadChapterNumber = allChapters
.sortedBy { it.chapterNumber }
.takeWhile { it.read }
.lastOrNull()
?.chapterNumber ?: -1.0
// TODO: merge into [SyncChapterProgressWithTrack]?
// Update chapter progress if newer chapters marked read locally
if (hasReadChapters) {
val latestLocalReadChapterNumber = allChapters
.sortedBy { it.chapterNumber }
.takeWhile { it.read }
.lastOrNull()
?.chapterNumber ?: -1.0
if (latestLocalReadChapterNumber > track.lastChapterRead) {
track = track.copy(
lastChapterRead = latestLocalReadChapterNumber,
)
tracker.setRemoteLastChapterRead(track.toDbTrack(), latestLocalReadChapterNumber.toInt())
}
if (track.startDate == null) {
val firstReadChapterDate = getHistory.await(mangaId)
.mapNotNull { it.readAt }
.minOrNull()
firstReadChapterDate?.let {
val startDate = firstReadChapterDate.toEpochMilliseconds()
if (latestLocalReadChapterNumber > track.lastChapterRead) {
track = track.copy(
startDate = Instant.fromEpochMilliseconds(startDate),
lastChapterRead = latestLocalReadChapterNumber,
)
tracker.setRemoteStartDate(track.toDbTrack(), startDate)
tracker.setRemoteLastChapterRead(track.toDbTrack(), latestLocalReadChapterNumber.toInt())
}
if (track.startDate <= 0) {
val firstReadChapterDate = Injekt.get<GetHistory>().await(mangaId)
.sortedBy { it.readAt }
.firstOrNull()
?.readAt
firstReadChapterDate?.let {
val startDate = firstReadChapterDate.time.convertEpochMillisZone(
ZoneOffset.systemDefault(),
ZoneOffset.UTC,
)
track = track.copy(
startDate = startDate,
)
tracker.setRemoteStartDate(track.toDbTrack(), startDate)
}
}
}
}
syncChapterProgressWithTrack.await(mangaId, track, tracker)
syncChapterProgressWithTrack.await(mangaId, track, tracker)
}
}
suspend fun bindEnhancedTrackers(manga: Manga, source: Source) = withContext(Dispatchers.IO) {
trackerManager.loggedInTrackers()
.filterIsInstance<EnhancedTracker>()
.filter { it.accept(source) }
.forEach { service ->
try {
service.match(manga)?.let { track ->
track.manga_id = manga.id
(service as Tracker).bind(track)
upsertTrack.await(track.toDomainTrack(idRequired = false)!!)
suspend fun bindEnhancedTrackers(manga: Manga, source: Source) = withNonCancellableContext {
withIOContext {
trackerManager.loggedInTrackers()
.filterIsInstance<EnhancedTracker>()
.filter { it.accept(source) }
.forEach { service ->
try {
service.match(manga)?.let { track ->
track.manga_id = manga.id
(service as Tracker).bind(track)
insertTrack.await(track.toDomainTrack(idRequired = false)!!)
syncChapterProgressWithTrack.await(
manga.id,
track.toDomainTrack(idRequired = false)!!,
service,
)
syncChapterProgressWithTrack.await(
manga.id,
track.toDomainTrack(idRequired = false)!!,
service,
)
}
} catch (e: Exception) {
logcat(
LogPriority.WARN,
e,
) { "Could not match manga: ${manga.title} with service $service" }
}
} catch (e: Exception) {
logcat(
LogPriority.WARN,
e,
) { "Could not match manga: ${manga.title} with service $service" }
}
}
}
}
}
@@ -1,22 +1,19 @@
package eu.kanade.domain.track.interactor
import dev.zacsweers.metro.Inject
import eu.kanade.domain.track.model.toDbTrack
import eu.kanade.domain.track.model.toDomainTrack
import eu.kanade.tachiyomi.data.track.Tracker
import eu.kanade.tachiyomi.data.track.TrackerManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.supervisorScope
import tachiyomi.domain.track.interactor.GetTracks
import tachiyomi.domain.track.interactor.UpsertTrack
import tachiyomi.domain.track.interactor.InsertTrack
@Inject
class RefreshTracks(
private val getTracks: GetTracks,
private val trackerManager: TrackerManager,
private val upsertTrack: UpsertTrack,
private val insertTrack: InsertTrack,
private val syncChapterProgressWithTrack: SyncChapterProgressWithTrack,
) {
@@ -31,10 +28,10 @@ class RefreshTracks(
.map { it to trackerManager.get(it.trackerId) }
.filter { (_, service) -> service?.isLoggedIn == true }
.map { (track, service) ->
async(Dispatchers.IO) {
async {
return@async try {
val updatedTrack = service!!.refresh(track.toDbTrack()).toDomainTrack()!!
upsertTrack.await(updatedTrack)
insertTrack.await(updatedTrack)
syncChapterProgressWithTrack.await(mangaId, updatedTrack, service)
null
} catch (e: Throwable) {
@@ -1,6 +1,5 @@
package eu.kanade.domain.track.interactor
import dev.zacsweers.metro.Inject
import eu.kanade.domain.track.model.toDbTrack
import eu.kanade.tachiyomi.data.track.EnhancedTracker
import eu.kanade.tachiyomi.data.track.Tracker
@@ -8,15 +7,14 @@ import logcat.LogPriority
import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
import tachiyomi.domain.chapter.interactor.UpdateChapter
import tachiyomi.domain.chapter.model.ChapterUpdate
import tachiyomi.domain.track.interactor.UpsertTrack
import tachiyomi.domain.chapter.model.toChapterUpdate
import tachiyomi.domain.track.interactor.InsertTrack
import tachiyomi.domain.track.model.Track
import kotlin.math.max
@Inject
class SyncChapterProgressWithTrack(
private val updateChapter: UpdateChapter,
private val upsertTrack: UpsertTrack,
private val insertTrack: InsertTrack,
private val getChaptersByMangaId: GetChaptersByMangaId,
) {
@@ -35,7 +33,7 @@ class SyncChapterProgressWithTrack(
val chapterUpdates = sortedChapters
.filter { chapter -> chapter.chapterNumber <= remoteTrack.lastChapterRead && !chapter.read }
.map { ChapterUpdate(it.id) { read = true } }
.map { it.copy(read = true).toChapterUpdate() }
// only take into account continuous reading
val localLastRead = sortedChapters.takeWhile { it.read }.lastOrNull()?.chapterNumber ?: 0F
@@ -45,7 +43,7 @@ class SyncChapterProgressWithTrack(
try {
tracker.update(updatedTrack.toDbTrack())
updateChapter.awaitAll(chapterUpdates)
upsertTrack.await(updatedTrack)
insertTrack.await(updatedTrack)
} catch (e: Throwable) {
logcat(LogPriority.WARN, e)
}
@@ -1,32 +1,30 @@
package eu.kanade.domain.track.interactor
import android.content.Context
import dev.zacsweers.metro.Inject
import eu.kanade.domain.track.model.toDbTrack
import eu.kanade.domain.track.model.toDomainTrack
import eu.kanade.domain.track.service.DelayedTrackingUpdateWorker
import eu.kanade.domain.track.service.DelayedTrackingUpdateJob
import eu.kanade.domain.track.store.DelayedTrackingStore
import eu.kanade.tachiyomi.data.track.TrackerManager
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import logcat.LogPriority
import tachiyomi.core.common.util.lang.withNonCancellableContext
import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.track.interactor.GetTracks
import tachiyomi.domain.track.interactor.UpsertTrack
import tachiyomi.domain.track.interactor.InsertTrack
@Inject
class TrackChapter(
private val getTracks: GetTracks,
private val trackerManager: TrackerManager,
private val upsertTrack: UpsertTrack,
private val insertTrack: InsertTrack,
private val delayedTrackingStore: DelayedTrackingStore,
) {
suspend fun await(context: Context, mangaId: Long, chapterNumber: Double, setupJobOnFailure: Boolean = true) {
coroutineScope {
withNonCancellableContext {
val tracks = getTracks.await(mangaId)
if (tracks.isEmpty()) return@coroutineScope
if (tracks.isEmpty()) return@withNonCancellableContext
tracks.mapNotNull { track ->
val service = trackerManager.get(track.trackerId)
@@ -41,12 +39,12 @@ class TrackChapter(
.toDomainTrack(idRequired = true)!!
.copy(lastChapterRead = chapterNumber)
service.update(updatedTrack.toDbTrack(), true)
upsertTrack.await(updatedTrack)
insertTrack.await(updatedTrack)
delayedTrackingStore.remove(track.id)
} catch (e: Exception) {
delayedTrackingStore.add(track.id, chapterNumber)
if (setupJobOnFailure) {
DelayedTrackingUpdateWorker.setupTask(context)
DelayedTrackingUpdateJob.setupTask(context)
}
throw e
}
@@ -1,7 +1,5 @@
package eu.kanade.domain.track.model
import mihon.core.common.extensions.toEpochMillisOrZero
import mihon.core.common.extensions.toInstantOrNull
import tachiyomi.domain.track.model.Track
import eu.kanade.tachiyomi.data.database.models.Track as DbTrack
@@ -27,8 +25,8 @@ fun Track.toDbTrack(): DbTrack = DbTrack.create(trackerId).also {
it.status = status
it.score = score
it.tracking_url = remoteUrl
it.started_reading_date = startDate.toEpochMillisOrZero()
it.finished_reading_date = finishDate.toEpochMillisOrZero()
it.started_reading_date = startDate
it.finished_reading_date = finishDate
it.private = private
}
@@ -46,8 +44,8 @@ fun DbTrack.toDomainTrack(idRequired: Boolean = true): Track? {
status = status,
score = score,
remoteUrl = tracking_url,
startDate = started_reading_date.toInstantOrNull(),
finishDate = finished_reading_date.toInstantOrNull(),
startDate = started_reading_date,
finishDate = finished_reading_date,
private = private,
)
}
@@ -8,38 +8,31 @@ import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkerParameters
import dev.zacsweers.metro.Inject
import eu.kanade.domain.track.interactor.TrackChapter
import eu.kanade.domain.track.store.DelayedTrackingStore
import eu.kanade.tachiyomi.util.system.workManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import logcat.LogPriority
import mihon.app.di.AppGraph
import mihon.core.metro.metroGraph
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.track.interactor.GetTracks
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.concurrent.TimeUnit
class DelayedTrackingUpdateWorker(private val context: Context, workerParams: WorkerParameters) :
class DelayedTrackingUpdateJob(private val context: Context, workerParams: WorkerParameters) :
CoroutineWorker(context, workerParams) {
private val graph: AppGraph = context.metroGraph()
@Inject lateinit var getTracks: GetTracks
@Inject lateinit var trackChapter: TrackChapter
@Inject lateinit var delayedTrackingStore: DelayedTrackingStore
override suspend fun doWork(): Result {
graph.inject(this)
if (runAttemptCount > 3) {
return Result.failure()
}
withContext(Dispatchers.IO) {
val getTracks = Injekt.get<GetTracks>()
val trackChapter = Injekt.get<TrackChapter>()
val delayedTrackingStore = Injekt.get<DelayedTrackingStore>()
withIOContext {
delayedTrackingStore.getItems()
.mapNotNull {
val track = getTracks.awaitOne(it.trackId)
@@ -67,7 +60,7 @@ class DelayedTrackingUpdateWorker(private val context: Context, workerParams: Wo
requiredNetworkType = NetworkType.CONNECTED,
)
val request = OneTimeWorkRequestBuilder<DelayedTrackingUpdateWorker>()
val request = OneTimeWorkRequestBuilder<DelayedTrackingUpdateJob>()
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 5, TimeUnit.MINUTES)
.addTag(TAG)
@@ -1,19 +1,13 @@
package eu.kanade.domain.track.service
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import eu.kanade.domain.track.model.AutoTrackState
import eu.kanade.tachiyomi.data.track.Tracker
import eu.kanade.tachiyomi.data.track.anilist.Anilist
import eu.kanade.tachiyomi.data.track.kitsu.Kitsu
import eu.kanade.tachiyomi.data.track.mangabaka.MangaBaka
import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.core.common.preference.getEnum
@Inject
@SingleIn(AppScope::class)
class TrackPreferences(
private val preferenceStore: PreferenceStore,
) {
@@ -48,8 +42,6 @@ class TrackPreferences(
val anilistScoreType: Preference<String> = preferenceStore.getString("anilist_score_type", Anilist.POINT_10)
val kitsuScoreType: Preference<String> = preferenceStore.getString("kitsu_score_type", Kitsu.RATING_ADVANCED)
val mangabakaScoreType: Preference<String> = preferenceStore.getString("mangabaka_score_type", MangaBaka.STEP_1)
val autoUpdateTrack: Preference<Boolean> = preferenceStore.getBoolean("pref_auto_update_manga_sync_key", true)
@@ -2,20 +2,15 @@ package eu.kanade.domain.track.store
import android.content.Context
import androidx.core.content.edit
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import logcat.LogPriority
import tachiyomi.core.common.util.system.logcat
@Inject
@SingleIn(AppScope::class)
class DelayedTrackingStore(context: Context) {
/**
* Preference file where queued tracking updates are stored.
*/
private val preferences by lazy { context.getSharedPreferences("tracking_queue", Context.MODE_PRIVATE) }
private val preferences = context.getSharedPreferences("tracking_queue", Context.MODE_PRIVATE)
fun add(trackId: Long, lastChapterRead: Double) {
val previousLastChapterRead = preferences.getFloat(trackId.toString(), 0f)
@@ -1,8 +1,5 @@
package eu.kanade.domain.ui
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import eu.kanade.domain.ui.model.AppTheme
import eu.kanade.domain.ui.model.TabletUiMode
import eu.kanade.domain.ui.model.ThemeMode
@@ -15,8 +12,6 @@ import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale
@Inject
@SingleIn(AppScope::class)
class UiPreferences(
preferenceStore: PreferenceStore,
) {
@@ -17,7 +17,6 @@ enum class AppTheme(val titleRes: StringResource?) {
TIDAL_WAVE(MR.strings.theme_tidalwave),
YINYANG(MR.strings.theme_yinyang),
YOTSUBA(MR.strings.theme_yotsuba),
TOKYONIGHT(MR.strings.theme_tokyonight),
MONOCHROME(MR.strings.theme_monochrome),
// Deprecated
@@ -3,6 +3,10 @@ package eu.kanade.presentation.browse
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.HelpOutline
import androidx.compose.material.icons.outlined.Public
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
@@ -19,10 +23,6 @@ import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.util.formattedMessage
import eu.kanade.tachiyomi.source.Source
import kotlinx.coroutines.flow.StateFlow
import mihon.icons.materialsymbols.MaterialSymbols
import mihon.icons.materialsymbols.automirroredrounded.Help
import mihon.icons.materialsymbols.rounded.Public
import mihon.icons.materialsymbols.rounded.Refresh
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.domain.library.model.LibraryDisplayMode
import tachiyomi.domain.manga.model.Manga
@@ -88,7 +88,7 @@ fun BrowseSourceContent(
listOf(
EmptyScreenAction(
stringRes = MR.strings.local_source_help_guide,
icon = MaterialSymbols.AutoMirroredRounded.Help,
icon = Icons.AutoMirrored.Outlined.HelpOutline,
onClick = onLocalSourceHelpClick,
),
)
@@ -96,17 +96,17 @@ fun BrowseSourceContent(
listOf(
EmptyScreenAction(
stringRes = MR.strings.action_retry,
icon = MaterialSymbols.Rounded.Refresh,
icon = Icons.Outlined.Refresh,
onClick = mangaList::refresh,
),
EmptyScreenAction(
stringRes = MR.strings.action_open_in_web_view,
icon = MaterialSymbols.Rounded.Public,
icon = Icons.Outlined.Public,
onClick = onWebViewClick,
),
EmptyScreenAction(
stringRes = MR.strings.label_help,
icon = MaterialSymbols.AutoMirroredRounded.Help,
icon = Icons.AutoMirrored.Outlined.HelpOutline,
onClick = onHelpClick,
),
)
@@ -14,6 +14,9 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.Launch
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
@@ -32,41 +35,38 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import dev.icerock.moko.resources.StringResource
import eu.kanade.domain.extension.interactor.ExtensionSourceItem
import eu.kanade.presentation.browse.components.ExtensionIcon
import eu.kanade.presentation.browse.components.ExtensionPill
import eu.kanade.presentation.browse.components.label
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.components.WarningBanner
import eu.kanade.presentation.more.settings.widget.TextPreferenceWidget
import eu.kanade.presentation.more.settings.widget.TrailingWidgetBuffer
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionDetailsViewModel
import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionDetailsScreenModel
import eu.kanade.tachiyomi.util.system.LocaleHelper
import eu.kanade.tachiyomi.util.system.copyToClipboard
import mihon.icons.materialsymbols.MaterialSymbols
import mihon.icons.materialsymbols.rounded.Eyeglasses2
import mihon.icons.materialsymbols.rounded.Settings
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.ScrollbarLazyColumn
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.screens.EmptyScreen
@Composable
fun ExtensionDetailsScreen(
navigateUp: () -> Unit,
state: ExtensionDetailsViewModel.State.Success,
state: ExtensionDetailsScreenModel.State,
onClickSourcePreferences: (sourceId: Long) -> Unit,
onClickEnableAll: () -> Unit,
onClickDisableAll: () -> Unit,
@@ -76,6 +76,15 @@ fun ExtensionDetailsScreen(
onClickIncognito: (Boolean) -> Unit,
) {
val uriHandler = LocalUriHandler.current
val url = remember(state.extension) {
val regex = """https://raw.githubusercontent.com/(.+?)/(.+?)/.+""".toRegex()
regex.find(state.extension?.store?.indexUrl.orEmpty())
?.let {
val (user, repo) = it.destructured
"https://github.com/$user/$repo"
}
?: state.extension?.store?.indexUrl
}
Scaffold(
topBar = { scrollBehavior ->
@@ -84,34 +93,52 @@ fun ExtensionDetailsScreen(
navigateUp = navigateUp,
actions = {
AppBarActions(
actions = listOf(
AppBar.OverflowAction(
title = stringResource(MR.strings.action_enable_all),
onClick = onClickEnableAll,
),
AppBar.OverflowAction(
title = stringResource(MR.strings.action_disable_all),
onClick = onClickDisableAll,
),
AppBar.OverflowAction(
title = stringResource(MR.strings.pref_clear_cookies),
onClick = onClickClearCookies,
),
),
actions = buildList {
if (url != null) {
add(
AppBar.Action(
title = stringResource(MR.strings.action_open_repo),
icon = Icons.AutoMirrored.Outlined.Launch,
onClick = {
uriHandler.openUri(url)
},
),
)
}
addAll(
listOf(
AppBar.OverflowAction(
title = stringResource(MR.strings.action_enable_all),
onClick = onClickEnableAll,
),
AppBar.OverflowAction(
title = stringResource(MR.strings.action_disable_all),
onClick = onClickDisableAll,
),
AppBar.OverflowAction(
title = stringResource(MR.strings.pref_clear_cookies),
onClick = onClickClearCookies,
),
),
)
},
)
},
scrollBehavior = scrollBehavior,
)
},
) { paddingValues ->
if (state.extension == null) {
EmptyScreen(
MR.strings.empty_screen,
modifier = Modifier.padding(paddingValues),
)
return@Scaffold
}
ExtensionDetails(
contentPadding = paddingValues,
extension = state.extension,
onClickStore = state.extension.store
?.contact
?.website
?.takeIf { it.isNotBlank() }
?.let { website -> { uriHandler.openUri(website) } },
sources = state.sources,
incognitoMode = state.isIncognito,
onClickSourcePreferences = onClickSourcePreferences,
@@ -125,8 +152,7 @@ fun ExtensionDetailsScreen(
@Composable
private fun ExtensionDetails(
contentPadding: PaddingValues,
extension: Extension.Loaded,
onClickStore: (() -> Unit)?,
extension: Extension.Installed,
sources: List<ExtensionSourceItem>,
incognitoMode: Boolean,
onClickSourcePreferences: (sourceId: Long) -> Unit,
@@ -135,8 +161,7 @@ private fun ExtensionDetails(
onClickIncognito: (Boolean) -> Unit,
) {
val context = LocalContext.current
var showContentWarning by remember { mutableStateOf(false) }
val contentWarning = extension.contentWarning.label
var showNsfwWarning by remember { mutableStateOf(false) }
ScrollbarLazyColumn(
contentPadding = contentPadding,
@@ -159,9 +184,8 @@ private fun ExtensionDetails(
}
Unit
}.takeIf { extension.isShared },
onClickStore = onClickStore,
onClickContentWarning = {
showContentWarning = true
onClickAgeRating = {
showNsfwWarning = true
},
onExtIncognitoChange = onClickIncognito,
)
@@ -179,12 +203,10 @@ private fun ExtensionDetails(
)
}
}
if (showContentWarning && contentWarning != null) {
ContentWarningDialog(
label = contentWarning.title,
description = contentWarning.description,
if (showNsfwWarning) {
NsfwWarningDialog(
onClickConfirm = {
showContentWarning = false
showNsfwWarning = false
},
)
}
@@ -192,16 +214,14 @@ private fun ExtensionDetails(
@Composable
private fun DetailsHeader(
extension: Extension.Installed,
extension: Extension,
extIncognitoMode: Boolean,
onClickStore: (() -> Unit)?,
onClickContentWarning: () -> Unit,
onClickAgeRating: () -> Unit,
onClickUninstall: () -> Unit,
onClickAppInfo: (() -> Unit)?,
onExtIncognitoChange: (Boolean) -> Unit,
) {
val context = LocalContext.current
val contentWarning = extension.contentWarning.label
Column {
Column(
@@ -218,11 +238,11 @@ private fun DetailsHeader(
"""
Extension name: ${extension.name} (lang: ${extension.lang}; package: ${extension.pkgName})
Extension version: ${extension.versionName} (lib: ${extension.libVersion}; version code: ${extension.versionCode})
Content warning: ${extension.contentWarning}
NSFW: ${extension.isNsfw}
""".trimIndent(),
)
if (extension is Extension.Loaded) {
if (extension is Extension.Installed) {
append("\n\n")
appendLine(
"""
@@ -254,42 +274,19 @@ private fun DetailsHeader(
textAlign = TextAlign.Center,
)
val strippedPkgName = extension.pkgName.substringAfter("eu.kanade.tachiyomi.extension.")
Text(
text = extension.pkgName,
textAlign = TextAlign.Center,
text = strippedPkgName,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
extension.store?.let { store ->
Text(
text = store.name,
modifier = Modifier
.clip(MaterialTheme.shapes.small)
.clickable(enabled = onClickStore != null) { onClickStore?.invoke() }
.padding(
horizontal = MaterialTheme.padding.extraSmall,
vertical = MaterialTheme.padding.extraSmall / 2,
),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
}
if ((extension as? Extension.Loaded)?.isShared == false) {
ExtensionPill(
text = stringResource(MR.strings.ext_installer_private),
modifier = Modifier.padding(top = MaterialTheme.padding.small),
)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = MaterialTheme.padding.medium,
horizontal = MaterialTheme.padding.extraLarge,
vertical = MaterialTheme.padding.small,
),
horizontalArrangement = Arrangement.SpaceEvenly,
@@ -304,20 +301,23 @@ private fun DetailsHeader(
InfoDivider()
InfoText(
modifier = Modifier.weight(1f),
modifier = Modifier.weight(if (extension.isNsfw) 1.5f else 1f),
primaryText = LocaleHelper.getSourceDisplayName(extension.lang, context),
secondaryText = stringResource(MR.strings.ext_info_language),
)
if (contentWarning != null) {
if (extension.isNsfw) {
InfoDivider()
InfoText(
modifier = Modifier.weight(1f),
primaryText = stringResource(contentWarning.title),
primaryTextColor = contentWarning.color,
secondaryText = stringResource(MR.strings.ext_info_warning),
onClick = onClickContentWarning,
primaryText = stringResource(MR.strings.ext_nsfw_short),
primaryTextStyle = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.error,
fontWeight = FontWeight.Medium,
),
secondaryText = stringResource(MR.strings.ext_info_age_rating),
onClick = onClickAgeRating,
)
}
}
@@ -340,7 +340,10 @@ private fun DetailsHeader(
modifier = Modifier.weight(1f),
onClick = onClickAppInfo,
) {
Text(stringResource(MR.strings.ext_app_info))
Text(
text = stringResource(MR.strings.ext_app_info),
color = MaterialTheme.colorScheme.onPrimary,
)
}
}
}
@@ -349,7 +352,7 @@ private fun DetailsHeader(
modifier = Modifier.padding(horizontal = MaterialTheme.padding.small),
title = stringResource(MR.strings.pref_incognito_mode),
subtitle = stringResource(MR.strings.pref_incognito_mode_extension_summary),
icon = MaterialSymbols.Rounded.Eyeglasses2,
icon = ImageVector.vectorResource(R.drawable.ic_glasses_24dp),
widget = {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -372,7 +375,7 @@ private fun InfoText(
primaryText: String,
secondaryText: String,
modifier: Modifier = Modifier,
primaryTextColor: Color = Color.Unspecified,
primaryTextStyle: TextStyle = MaterialTheme.typography.bodyLarge,
onClick: (() -> Unit)? = null,
) {
val clickableModifier = if (onClick != null) {
@@ -389,16 +392,13 @@ private fun InfoText(
Text(
text = primaryText,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleSmall,
color = primaryTextColor,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = primaryTextStyle,
)
Text(
text = secondaryText + if (onClick != null) " ⓘ" else "",
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
)
}
@@ -407,9 +407,7 @@ private fun InfoText(
@Composable
private fun InfoDivider() {
VerticalDivider(
modifier = Modifier
.padding(horizontal = MaterialTheme.padding.small)
.height(24.dp),
modifier = Modifier.height(20.dp),
)
}
@@ -436,7 +434,7 @@ private fun SourceSwitchPreference(
if (source.source is ConfigurableSource) {
IconButton(onClick = { onClickSourcePreferences(source.source.id) }) {
Icon(
imageVector = MaterialSymbols.Rounded.Settings,
imageVector = Icons.Outlined.Settings,
contentDescription = stringResource(MR.strings.label_settings),
tint = MaterialTheme.colorScheme.onSurface,
)
@@ -455,20 +453,12 @@ private fun SourceSwitchPreference(
}
@Composable
private fun ContentWarningDialog(
label: StringResource,
description: StringResource,
private fun NsfwWarningDialog(
onClickConfirm: () -> Unit,
) {
AlertDialog(
title = {
Text(
text = stringResource(label),
style = MaterialTheme.typography.titleMedium,
)
},
text = {
Text(text = stringResource(description))
Text(text = stringResource(MR.strings.ext_nsfw_warning))
},
confirmButton = {
TextButton(onClick = onClickConfirm) {
@@ -13,7 +13,13 @@ import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.GetApp
import androidx.compose.material.icons.outlined.Public
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.VerifiedUser
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
@@ -21,6 +27,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -30,9 +37,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.navigator.LocalNavigator
@@ -40,28 +45,17 @@ import cafe.adriel.voyager.navigator.currentOrThrow
import dev.icerock.moko.resources.StringResource
import eu.kanade.presentation.browse.components.BaseBrowseItem
import eu.kanade.presentation.browse.components.ExtensionIcon
import eu.kanade.presentation.browse.components.ExtensionPill
import eu.kanade.presentation.browse.components.label
import eu.kanade.presentation.components.WarningBanner
import eu.kanade.presentation.manga.components.DotSeparatorNoSpaceText
import eu.kanade.presentation.more.settings.screen.browse.ExtensionStoresScreen
import eu.kanade.presentation.util.animateItemFastScroll
import eu.kanade.presentation.util.rememberRequestPackageInstallsPermissionState
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.ui.browse.extension.ExtensionUiModel
import eu.kanade.tachiyomi.ui.browse.extension.ExtensionsViewModel
import eu.kanade.tachiyomi.ui.browse.extension.ExtensionsScreenModel
import eu.kanade.tachiyomi.util.system.LocaleHelper
import eu.kanade.tachiyomi.util.system.copyToClipboard
import eu.kanade.tachiyomi.util.system.launchRequestPackageInstallsPermission
import mihon.domain.extension.model.ExtensionStore
import mihon.icons.materialsymbols.MaterialSymbols
import mihon.icons.materialsymbols.rounded.Close
import mihon.icons.materialsymbols.rounded.Download
import mihon.icons.materialsymbols.rounded.Info
import mihon.icons.materialsymbols.rounded.Public
import mihon.icons.materialsymbols.rounded.Refresh
import mihon.icons.materialsymbols.rounded.Settings
import mihon.icons.materialsymbols.rounded.VerifiedUser
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.FastScrollLazyColumn
import tachiyomi.presentation.core.components.material.PullRefresh
@@ -73,20 +67,21 @@ import tachiyomi.presentation.core.screens.EmptyScreenAction
import tachiyomi.presentation.core.screens.LoadingScreen
import tachiyomi.presentation.core.theme.header
import tachiyomi.presentation.core.util.plus
import tachiyomi.presentation.core.util.secondaryItemAlpha
@Composable
fun ExtensionScreen(
state: ExtensionsViewModel.State,
state: ExtensionsScreenModel.State,
contentPadding: PaddingValues,
searchQuery: String?,
onLongClickItem: (Extension) -> Unit,
onClickItemCancel: (Extension) -> Unit,
onOpenWebView: (Extension.Available) -> Unit,
onInstallExtension: (Extension.Available) -> Unit,
onUninstallExtension: (Extension.Installed) -> Unit,
onUninstallExtension: (Extension) -> Unit,
onUpdateExtension: (Extension.Installed) -> Unit,
onTrustExtension: (Extension.NotLoaded) -> Unit,
onOpenExtension: (Extension.Loaded) -> Unit,
onTrustExtension: (Extension.Untrusted) -> Unit,
onOpenExtension: (Extension.Installed) -> Unit,
onClickUpdateAll: () -> Unit,
onRefresh: () -> Unit,
) {
@@ -111,7 +106,7 @@ fun ExtensionScreen(
actions = listOf(
EmptyScreenAction(
stringRes = MR.strings.extensionStores,
icon = MaterialSymbols.Rounded.Settings,
icon = Icons.Outlined.Settings,
onClick = { navigator.push(ExtensionStoresScreen()) },
),
),
@@ -138,20 +133,20 @@ fun ExtensionScreen(
@Composable
private fun ExtensionContent(
state: ExtensionsViewModel.State,
state: ExtensionsScreenModel.State,
contentPadding: PaddingValues,
onLongClickItem: (Extension) -> Unit,
onClickItemCancel: (Extension) -> Unit,
onOpenWebView: (Extension.Available) -> Unit,
onInstallExtension: (Extension.Available) -> Unit,
onUninstallExtension: (Extension.Installed) -> Unit,
onUninstallExtension: (Extension) -> Unit,
onUpdateExtension: (Extension.Installed) -> Unit,
onTrustExtension: (Extension.NotLoaded) -> Unit,
onOpenExtension: (Extension.Loaded) -> Unit,
onTrustExtension: (Extension.Untrusted) -> Unit,
onOpenExtension: (Extension.Installed) -> Unit,
onClickUpdateAll: () -> Unit,
) {
val context = LocalContext.current
var notLoadedState by remember { mutableStateOf<Extension.NotLoaded?>(null) }
var trustState by remember { mutableStateOf<Extension.Untrusted?>(null) }
val installGranted = rememberRequestPackageInstallsPermissionState(initialValue = true)
FastScrollLazyColumn(
@@ -192,14 +187,14 @@ private fun ExtensionContent(
}
ExtensionHeader(
textRes = header.textRes,
modifier = Modifier.animateItem(),
modifier = Modifier.animateItemFastScroll(),
action = action,
)
}
is ExtensionUiModel.Header.Text -> {
ExtensionHeader(
text = header.text,
modifier = Modifier.animateItem(),
modifier = Modifier.animateItemFastScroll(),
)
}
}
@@ -210,21 +205,21 @@ private fun ExtensionContent(
contentType = { "item" },
key = { item ->
when (item.extension) {
is Extension.NotLoaded -> "extension-not-loaded-${item.hashCode()}"
is Extension.Loaded -> "extension-loaded-${item.hashCode()}"
is Extension.Untrusted -> "extension-untrusted-${item.hashCode()}"
is Extension.Installed -> "extension-installed-${item.hashCode()}"
is Extension.Available -> "extension-available-${item.hashCode()}"
}
},
) { item ->
ExtensionItem(
modifier = Modifier.animateItem(),
modifier = Modifier.animateItemFastScroll(),
item = item,
onClickItem = {
when (it) {
is Extension.Available -> onInstallExtension(it)
is Extension.Loaded -> onOpenExtension(it)
is Extension.NotLoaded -> {
notLoadedState = it
is Extension.Installed -> onOpenExtension(it)
is Extension.Untrusted -> {
trustState = it
}
}
},
@@ -232,29 +227,23 @@ private fun ExtensionContent(
onClickItemSecondaryAction = {
when (it) {
is Extension.Available -> onOpenWebView(it)
is Extension.Loaded -> onOpenExtension(it)
is Extension.NotLoaded -> {
notLoadedState = it
}
is Extension.Installed -> onOpenExtension(it)
else -> {}
}
},
onClickItemCancel = onClickItemCancel,
onClickItemAction = {
when (it) {
is Extension.Available -> onInstallExtension(it)
is Extension.Loaded -> {
is Extension.Installed -> {
if (it.hasUpdate) {
onUpdateExtension(it)
} else {
onOpenExtension(it)
}
}
is Extension.NotLoaded -> {
if (it.hasUpdate) {
onUpdateExtension(it)
} else {
notLoadedState = it
}
is Extension.Untrusted -> {
trustState = it
}
}
},
@@ -262,30 +251,20 @@ private fun ExtensionContent(
}
}
}
notLoadedState?.let { extension ->
val dismiss = { notLoadedState = null }
if (extension.reason is Extension.NotLoaded.Reason.Untrusted) {
ExtensionTrustDialog(
onClickConfirm = {
onTrustExtension(extension)
dismiss()
},
onClickDismiss = {
onUninstallExtension(extension)
dismiss()
},
onDismissRequest = dismiss,
)
} else {
ExtensionNotLoadedDialog(
reason = extension.reason,
onClickUninstall = {
onUninstallExtension(extension)
dismiss()
},
onDismissRequest = dismiss,
)
}
if (trustState != null) {
ExtensionTrustDialog(
onClickConfirm = {
onTrustExtension(trustState!!)
trustState = null
},
onClickDismiss = {
onUninstallExtension(trustState!!)
trustState = null
},
onDismissRequest = {
trustState = null
},
)
}
}
@@ -300,17 +279,39 @@ private fun ExtensionItem(
modifier: Modifier = Modifier,
) {
val (extension, installStep) = item
val store = when (extension) {
is Extension.Available -> extension.store
is Extension.Installed -> extension.store
}
BaseBrowseItem(
modifier = modifier,
modifier = modifier
.combinedClickable(
onClick = { onClickItem(extension) },
onLongClick = { onLongClickItem(extension) },
),
onClickItem = { onClickItem(extension) },
onLongClickItem = { onLongClickItem(extension) },
icon = {
ExtensionItemIcon(extension = extension, installStep = installStep)
Box(
modifier = Modifier
.size(40.dp),
contentAlignment = Alignment.Center,
) {
val idle = installStep.isCompleted()
if (!idle) {
CircularProgressIndicator(
modifier = Modifier.size(40.dp),
strokeWidth = 2.dp,
)
}
val padding by animateDpAsState(
targetValue = if (idle) 0.dp else 8.dp,
label = "iconPadding",
)
ExtensionIcon(
extension = extension,
modifier = Modifier
.matchParentSize()
.padding(padding),
)
}
},
action = {
ExtensionItemActions(
@@ -325,7 +326,6 @@ private fun ExtensionItem(
ExtensionItemContent(
extension = extension,
installStep = installStep,
store = store,
modifier = Modifier.weight(1f),
)
}
@@ -335,7 +335,6 @@ private fun ExtensionItem(
private fun ExtensionItemContent(
extension: Extension,
installStep: InstallStep,
store: ExtensionStore?,
modifier: Modifier = Modifier,
) {
Column(
@@ -347,106 +346,63 @@ private fun ExtensionItemContent(
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyMedium,
)
// Won't look good but it's not like we can ellipsize overflowing content
FlowRow(
modifier = Modifier.secondaryItemAlpha(),
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.extraSmall),
) {
ProvideTextStyle(value = MaterialTheme.typography.bodySmall) {
var hasAlreadyShownAnElement by remember { mutableStateOf(false) }
if (extension is Extension.Installed && extension.lang.isNotEmpty()) {
hasAlreadyShownAnElement = true
Text(
text = LocaleHelper.getSourceDisplayName(extension.lang, LocalContext.current),
)
}
if (store != null) {
Text(
text = store.name,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (extension.versionName.isNotEmpty()) {
if (hasAlreadyShownAnElement) DotSeparatorNoSpaceText()
hasAlreadyShownAnElement = true
Text(
text = extension.versionName,
)
}
ExtensionItemMetadata(extension = extension, installStep = installStep)
}
}
val warning = when {
extension is Extension.Untrusted -> MR.strings.ext_untrusted
extension is Extension.Installed && extension.isObsolete -> MR.strings.ext_obsolete
extension.isNsfw -> MR.strings.ext_nsfw_short
else -> null
}
if (warning != null) {
if (hasAlreadyShownAnElement) DotSeparatorNoSpaceText()
hasAlreadyShownAnElement = true
Text(
text = stringResource(warning).uppercase(),
color = MaterialTheme.colorScheme.error,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (extension is Extension.Installed && !extension.isShared) {
if (hasAlreadyShownAnElement) DotSeparatorNoSpaceText()
Text(
text = stringResource(MR.strings.ext_installer_private),
)
}
@Composable
private fun ExtensionItemIcon(
extension: Extension,
installStep: InstallStep,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.size(48.dp),
contentAlignment = Alignment.Center,
) {
val idle = installStep.isCompleted()
if (!idle) {
CircularProgressIndicator(
modifier = Modifier.size(48.dp),
strokeWidth = 2.dp,
)
}
val padding by animateDpAsState(
targetValue = if (idle) 0.dp else 8.dp,
label = "iconPadding",
)
ExtensionIcon(
extension = extension,
modifier = Modifier
.matchParentSize()
.padding(padding),
)
}
}
/**
* Plain text for what an extension simply is, pills for the few things worth noticing about it.
* Won't look good when it wraps, but it's not like overflowing content can be ellipsized.
*/
@Composable
private fun ExtensionItemMetadata(
extension: Extension,
installStep: InstallStep,
modifier: Modifier = Modifier,
) {
FlowRow(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.extraSmall),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.extraSmall / 2),
itemVerticalAlignment = Alignment.CenterVertically,
) {
val facts = listOfNotNull(
(extension as? Extension.Loaded)
?.lang
?.takeIf { it.isNotEmpty() }
?.let { LocaleHelper.getSourceDisplayName(it, LocalContext.current) },
extension.versionName.takeIf { it.isNotEmpty() },
)
if (facts.isNotEmpty()) {
Text(
text = facts.joinToString(" \u2022 "),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
val notable = listOfNotNull(
when (installStep) {
InstallStep.Pending -> MR.strings.ext_pending
InstallStep.Downloading -> MR.strings.ext_downloading
InstallStep.Installing -> MR.strings.ext_installing
else -> null
}?.let { it to MaterialTheme.colorScheme.primary },
when {
extension is Extension.NotLoaded ->
extension.reason.labelRes?.let { it to MaterialTheme.colorScheme.error }
extension is Extension.Loaded && extension.isObsolete ->
MR.strings.ext_obsolete to MaterialTheme.colorScheme.error
else -> null
},
extension.contentWarning.label?.let { it.title to it.color },
(extension as? Extension.Loaded)
?.takeIf { !it.isShared }
?.let { MR.strings.ext_installer_private to MaterialTheme.colorScheme.onSurfaceVariant },
)
notable.forEach { (label, color) ->
ExtensionPill(text = stringResource(label), color = color)
if (!installStep.isCompleted()) {
DotSeparatorNoSpaceText()
Text(
text = when (installStep) {
InstallStep.Pending -> stringResource(MR.strings.ext_pending)
InstallStep.Downloading -> stringResource(MR.strings.ext_downloading)
InstallStep.Installing -> stringResource(MR.strings.ext_installing)
else -> error("Must not show non-install process text")
},
)
}
}
}
}
}
@@ -460,50 +416,76 @@ private fun ExtensionItemActions(
onClickItemAction: (Extension) -> Unit = {},
onClickItemSecondaryAction: (Extension) -> Unit = {},
) {
val isIdle = installStep.isCompleted()
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
verticalAlignment = Alignment.CenterVertically,
) {
if (!installStep.isCompleted()) {
IconButton(onClick = { onClickItemCancel(extension) }) {
Icon(
imageVector = MaterialSymbols.Rounded.Close,
contentDescription = stringResource(MR.strings.action_cancel),
)
when {
!isIdle -> {
IconButton(onClick = { onClickItemCancel(extension) }) {
Icon(
imageVector = Icons.Outlined.Close,
contentDescription = stringResource(MR.strings.action_cancel),
)
}
}
return@Row
}
installStep == InstallStep.Error -> {
IconButton(onClick = { onClickItemAction(extension) }) {
Icon(
imageVector = Icons.Outlined.Refresh,
contentDescription = stringResource(MR.strings.action_retry),
)
}
}
installStep == InstallStep.Idle -> {
when (extension) {
is Extension.Installed -> {
IconButton(onClick = { onClickItemSecondaryAction(extension) }) {
Icon(
imageVector = Icons.Outlined.Settings,
contentDescription = stringResource(MR.strings.action_settings),
)
}
val secondaryAction = when (extension) {
is Extension.Available -> {
(MaterialSymbols.Rounded.Public to MR.strings.action_open_in_web_view)
.takeIf { extension.sources.isNotEmpty() }
}
is Extension.Loaded -> MaterialSymbols.Rounded.Settings to MR.strings.action_settings
is Extension.NotLoaded -> if (extension.reason is Extension.NotLoaded.Reason.Untrusted) {
MaterialSymbols.Rounded.VerifiedUser to MR.strings.ext_trust
} else {
MaterialSymbols.Rounded.Info to MR.strings.ext_not_loaded_details
}
}
secondaryAction?.let { (icon, label) ->
IconButton(onClick = { onClickItemSecondaryAction(extension) }) {
Icon(imageVector = icon, contentDescription = stringResource(label))
}
}
if (extension.hasUpdate) {
IconButton(onClick = { onClickItemAction(extension) }) {
Icon(
imageVector = Icons.Outlined.GetApp,
contentDescription = stringResource(MR.strings.ext_update),
)
}
}
}
is Extension.Untrusted -> {
IconButton(onClick = { onClickItemAction(extension) }) {
Icon(
imageVector = Icons.Outlined.VerifiedUser,
contentDescription = stringResource(MR.strings.ext_trust),
)
}
}
is Extension.Available -> {
if (extension.sources.isNotEmpty()) {
IconButton(
onClick = { onClickItemSecondaryAction(extension) },
) {
Icon(
imageVector = Icons.Outlined.Public,
contentDescription = stringResource(MR.strings.action_open_in_web_view),
)
}
}
val action = when {
installStep == InstallStep.Error -> MaterialSymbols.Rounded.Refresh to MR.strings.action_retry
extension is Extension.Available -> MaterialSymbols.Rounded.Download to MR.strings.ext_install
extension is Extension.Installed && extension.hasUpdate -> {
MaterialSymbols.Rounded.Download to MR.strings.ext_update
}
else -> null
}
action?.let { (icon, label) ->
IconButton(onClick = { onClickItemAction(extension) }) {
Icon(imageVector = icon, contentDescription = stringResource(label))
IconButton(onClick = { onClickItemAction(extension) }) {
Icon(
imageVector = Icons.Outlined.GetApp,
contentDescription = stringResource(MR.strings.ext_install),
)
}
}
}
}
}
}
@@ -543,83 +525,6 @@ private fun ExtensionHeader(
}
}
/**
* Only the reasons the user can act on are worth naming in the row; the rest all mean "broken" to
* them and are spelled out in [ExtensionNotLoadedDialog] instead.
*/
private val Extension.NotLoaded.Reason.labelRes: StringResource?
get() = when (this) {
is Extension.NotLoaded.Reason.Untrusted -> MR.strings.ext_untrusted
Extension.NotLoaded.Reason.Filtered -> MR.strings.ext_filtered
// The section header already says these aren't loaded; the dialog says why
Extension.NotLoaded.Reason.Unsigned,
Extension.NotLoaded.Reason.UnsupportedLibVersion,
Extension.NotLoaded.Reason.Malformed,
is Extension.NotLoaded.Reason.Failed,
-> null
}
private val Extension.NotLoaded.Reason.descriptionRes: StringResource
get() = when (this) {
is Extension.NotLoaded.Reason.Untrusted -> MR.strings.untrusted_extension_message
Extension.NotLoaded.Reason.Filtered -> MR.strings.ext_filtered_message
Extension.NotLoaded.Reason.Unsigned -> MR.strings.ext_unsigned_message
Extension.NotLoaded.Reason.UnsupportedLibVersion -> MR.strings.ext_unsupported_message
Extension.NotLoaded.Reason.Malformed -> MR.strings.ext_malformed_message
is Extension.NotLoaded.Reason.Failed -> MR.strings.ext_load_failed_message
}
@Composable
private fun ExtensionNotLoadedDialog(
reason: Extension.NotLoaded.Reason,
onClickUninstall: () -> Unit,
onDismissRequest: () -> Unit,
) {
AlertDialog(
title = {
Text(text = stringResource(MR.strings.ext_not_loaded_dialog))
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small)) {
Text(text = stringResource(reason.descriptionRes))
if (reason is Extension.NotLoaded.Reason.Failed) {
Text(
text = reason.message,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val context = LocalContext.current
TextButton(
onClick = {
context.copyToClipboard(
label = context.stringResource(MR.strings.ext_copy_stacktrace),
content = reason.stackTrace,
)
},
contentPadding = PaddingValues(0.dp),
) {
Text(text = stringResource(MR.strings.ext_copy_stacktrace))
}
}
}
},
confirmButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(MR.strings.action_ok))
}
},
dismissButton = {
TextButton(onClick = onClickUninstall) {
Text(text = stringResource(MR.strings.ext_uninstall))
}
},
onDismissRequest = onDismissRequest,
)
}
@Composable
private fun ExtensionTrustDialog(
onClickConfirm: () -> Unit,
@@ -12,7 +12,7 @@ import eu.kanade.presentation.browse.components.GlobalSearchResultItem
import eu.kanade.presentation.browse.components.GlobalSearchToolbar
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SearchItemResult
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SearchViewModel
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SearchScreenModel
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SourceFilter
import eu.kanade.tachiyomi.util.system.LocaleHelper
import tachiyomi.domain.manga.model.Manga
@@ -20,7 +20,7 @@ import tachiyomi.presentation.core.components.material.Scaffold
@Composable
fun GlobalSearchScreen(
state: SearchViewModel.State,
state: SearchScreenModel.State,
navigateUp: () -> Unit,
onChangeSearchQuery: (String?) -> Unit,
onSearch: (String) -> Unit,
@@ -4,14 +4,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import eu.kanade.presentation.browse.components.GlobalSearchToolbar
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SearchViewModel
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SearchScreenModel
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SourceFilter
import tachiyomi.domain.manga.model.Manga
import tachiyomi.presentation.core.components.material.Scaffold
@Composable
fun MigrateSearchScreen(
state: SearchViewModel.State,
state: SearchScreenModel.State,
fromSourceId: Long?,
navigateUp: () -> Unit,
onChangeSearchQuery: (String?) -> Unit,
@@ -6,8 +6,12 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowDownward
import androidx.compose.material.icons.outlined.ArrowUpward
import androidx.compose.material.icons.outlined.Numbers
import androidx.compose.material.icons.outlined.SortByAlpha
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -17,17 +21,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import eu.kanade.domain.source.interactor.SetMigrateSorting
import eu.kanade.presentation.browse.components.BaseSourceItem
import eu.kanade.presentation.browse.components.SourceIcon
import eu.kanade.tachiyomi.ui.browse.migration.sources.MigrateSourceViewModel
import eu.kanade.tachiyomi.ui.browse.migration.sources.MigrateSourceScreenModel
import eu.kanade.tachiyomi.util.system.copyToClipboard
import mihon.icons.materialsymbols.MaterialSymbols
import mihon.icons.materialsymbols.rounded.ArrowDownward
import mihon.icons.materialsymbols.rounded.ArrowUpward
import mihon.icons.materialsymbols.rounded.Numbers
import mihon.icons.materialsymbols.rounded.SortByAlpha
import tachiyomi.domain.source.model.Source
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.Badge
@@ -45,7 +43,7 @@ import tachiyomi.presentation.core.util.secondaryItemAlpha
@Composable
fun MigrateSourceScreen(
state: MigrateSourceViewModel.State,
state: MigrateSourceScreenModel.State,
contentPadding: PaddingValues,
onClickItem: (Source) -> Unit,
onToggleSortingDirection: () -> Unit,
@@ -105,11 +103,11 @@ private fun MigrateSourceList(
IconButton(onClick = onToggleSortingMode) {
when (sortingMode) {
SetMigrateSorting.Mode.ALPHABETICAL -> Icon(
MaterialSymbols.Rounded.SortByAlpha,
Icons.Outlined.SortByAlpha,
contentDescription = stringResource(MR.strings.action_sort_alpha),
)
SetMigrateSorting.Mode.TOTAL -> Icon(
MaterialSymbols.Rounded.Numbers,
Icons.Outlined.Numbers,
contentDescription = stringResource(MR.strings.action_sort_count),
)
}
@@ -117,11 +115,11 @@ private fun MigrateSourceList(
IconButton(onClick = onToggleSortingDirection) {
when (sortingDirection) {
SetMigrateSorting.Direction.ASCENDING -> Icon(
MaterialSymbols.Rounded.ArrowUpward,
Icons.Outlined.ArrowUpward,
contentDescription = stringResource(MR.strings.action_asc),
)
SetMigrateSorting.Direction.DESCENDING -> Icon(
MaterialSymbols.Rounded.ArrowDownward,
Icons.Outlined.ArrowDownward,
contentDescription = stringResource(MR.strings.action_desc),
)
}
@@ -158,7 +156,7 @@ private fun MigrateSourceItem(
showLanguageInContent = source.lang != "",
onClickItem = onClickItem,
onLongClickItem = onLongClickItem,
icon = { SourceIcon(source = source, modifier = Modifier.size(48.dp)) },
icon = { SourceIcon(source = source) },
action = {
BadgeGroup {
Badge(text = "$count")
@@ -10,7 +10,8 @@ import androidx.compose.ui.platform.LocalContext
import eu.kanade.presentation.browse.components.BaseSourceItem
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.more.settings.widget.SwitchPreferenceWidget
import eu.kanade.tachiyomi.ui.browse.source.SourcesFilterViewModel
import eu.kanade.presentation.util.animateItemFastScroll
import eu.kanade.tachiyomi.ui.browse.source.SourcesFilterScreenModel
import eu.kanade.tachiyomi.util.system.LocaleHelper
import tachiyomi.domain.source.model.Source
import tachiyomi.i18n.MR
@@ -22,7 +23,7 @@ import tachiyomi.presentation.core.screens.EmptyScreen
@Composable
fun SourcesFilterScreen(
navigateUp: () -> Unit,
state: SourcesFilterViewModel.State.Success,
state: SourcesFilterScreenModel.State.Success,
onClickLanguage: (String) -> Unit,
onClickSource: (Source) -> Unit,
) {
@@ -54,7 +55,7 @@ fun SourcesFilterScreen(
@Composable
private fun SourcesFilterContent(
contentPadding: PaddingValues,
state: SourcesFilterViewModel.State.Success,
state: SourcesFilterScreenModel.State.Success,
onClickLanguage: (String) -> Unit,
onClickSource: (Source) -> Unit,
) {
@@ -68,7 +69,7 @@ private fun SourcesFilterContent(
contentType = "source-filter-header",
) {
SourcesFilterHeader(
modifier = Modifier.animateItem(),
modifier = Modifier.animateItemFastScroll(),
language = language,
enabled = enabled,
onClickItem = onClickLanguage,
@@ -81,7 +82,7 @@ private fun SourcesFilterContent(
contentType = { "source-filter-item" },
) { source ->
SourcesFilterItem(
modifier = Modifier.animateItem(),
modifier = Modifier.animateItemFastScroll(),
source = source,
enabled = "${source.id}" !in state.disabledSources,
onClickItem = onClickSource,
@@ -6,6 +6,9 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.outlined.PushPin
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -18,12 +21,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import eu.kanade.presentation.browse.components.BaseSourceItem
import eu.kanade.tachiyomi.ui.browse.source.SourcesViewModel
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceViewModel.Listing
import eu.kanade.tachiyomi.ui.browse.source.SourcesScreenModel
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreenModel.Listing
import eu.kanade.tachiyomi.util.system.LocaleHelper
import mihon.icons.materialsymbols.MaterialSymbols
import mihon.icons.materialsymbols.rounded.Keep
import mihon.icons.materialsymbols.roundedfilled.Keep
import tachiyomi.domain.source.model.Pin
import tachiyomi.domain.source.model.Source
import tachiyomi.i18n.MR
@@ -40,7 +40,7 @@ import tachiyomi.source.local.isLocal
@Composable
fun SourcesScreen(
state: SourcesViewModel.State,
state: SourcesScreenModel.State,
contentPadding: PaddingValues,
onClickItem: (Source, Listing) -> Unit,
onClickPin: (Source) -> Unit,
@@ -143,7 +143,7 @@ private fun SourcePinButton(
isPinned: Boolean,
onClick: () -> Unit,
) {
val icon = if (isPinned) MaterialSymbols.RoundedFilled.Keep else MaterialSymbols.Rounded.Keep
val icon = if (isPinned) Icons.Filled.PushPin else Icons.Outlined.PushPin
val tint = if (isPinned) {
MaterialTheme.colorScheme.primary
} else {
@@ -3,14 +3,12 @@ package eu.kanade.presentation.browse.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import eu.kanade.tachiyomi.util.system.LocaleHelper
import tachiyomi.domain.source.model.Source
import tachiyomi.presentation.core.components.material.padding
@@ -41,7 +39,7 @@ fun BaseSourceItem(
}
private val defaultIcon: @Composable RowScope.(Source) -> Unit = { source ->
SourceIcon(source = source, modifier = Modifier.size(48.dp))
SourceIcon(source = source)
}
private val defaultContent: @Composable RowScope.(Source, String?) -> Unit = { source, sourceLangString ->
@@ -1,15 +1,15 @@
package eu.kanade.presentation.browse.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CollectionsBookmark
import androidx.compose.runtime.Composable
import mihon.icons.materialsymbols.MaterialSymbols
import mihon.icons.materialsymbols.rounded.CollectionsBookmark
import tachiyomi.presentation.core.components.Badge
@Composable
internal fun InLibraryBadge(enabled: Boolean) {
if (enabled) {
Badge(
imageVector = MaterialSymbols.Rounded.CollectionsBookmark,
imageVector = Icons.Outlined.CollectionsBookmark,
)
}
}
@@ -2,26 +2,36 @@ package eu.kanade.presentation.browse.components
import android.util.DisplayMetrics
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Dangerous
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.ColorPainter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.imageResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.core.graphics.drawable.toBitmap
import coil3.compose.AsyncImage
import eu.kanade.domain.source.model.icon
import eu.kanade.presentation.util.rememberResourceBitmapPainter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.coil.InstalledExtensionIcon
import eu.kanade.tachiyomi.data.coil.InstalledSourceIcon
import eu.kanade.tachiyomi.extension.model.Extension
import mihon.icons.materialsymbols.MaterialSymbols
import mihon.icons.materialsymbols.roundedfilled.Dangerous
import mihon.icons.materialsymbols.roundedfilled.Warning
import eu.kanade.tachiyomi.extension.util.ExtensionLoader
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.domain.source.model.Source
import tachiyomi.source.local.isLocal
@@ -34,15 +44,24 @@ fun SourceIcon(
source: Source,
modifier: Modifier = Modifier,
) {
val icon = source.icon
when {
source.isStub -> {
source.isStub && icon == null -> {
Image(
imageVector = MaterialSymbols.RoundedFilled.Warning,
imageVector = Icons.Filled.Warning,
contentDescription = null,
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.error),
modifier = modifier.then(defaultModifier),
)
}
icon != null -> {
Image(
bitmap = icon,
contentDescription = null,
modifier = modifier.then(defaultModifier),
)
}
source.isLocal() -> {
Image(
painter = painterResource(R.mipmap.ic_local_source),
@@ -51,12 +70,9 @@ fun SourceIcon(
)
}
else -> {
val defaultIcon = painterResource(R.mipmap.ic_default_source)
AsyncImage(
model = InstalledSourceIcon(source.id),
Image(
painter = painterResource(R.mipmap.ic_default_source),
contentDescription = null,
placeholder = defaultIcon,
error = defaultIcon,
modifier = modifier.then(defaultModifier),
)
}
@@ -80,21 +96,53 @@ fun ExtensionIcon(
.clip(MaterialTheme.shapes.extraSmall),
)
}
is Extension.Loaded -> {
val defaultIcon = painterResource(R.mipmap.ic_default_source)
AsyncImage(
model = InstalledExtensionIcon(extension.pkgName, extension.versionCode, density),
contentDescription = null,
placeholder = defaultIcon,
error = defaultIcon,
modifier = modifier,
)
is Extension.Installed -> {
val icon by extension.getIcon(density)
when (icon) {
Result.Loading -> Box(modifier = modifier)
is Result.Success -> Image(
bitmap = (icon as Result.Success<ImageBitmap>).value,
contentDescription = null,
modifier = modifier,
)
Result.Error -> Image(
bitmap = ImageBitmap.imageResource(id = R.mipmap.ic_default_source),
contentDescription = null,
modifier = modifier,
)
}
}
is Extension.NotLoaded -> Image(
imageVector = MaterialSymbols.RoundedFilled.Dangerous,
is Extension.Untrusted -> Image(
imageVector = Icons.Filled.Dangerous,
contentDescription = null,
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.error),
modifier = modifier.then(defaultModifier),
)
}
}
@Composable
private fun Extension.getIcon(density: Int = DisplayMetrics.DENSITY_DEFAULT): State<Result<ImageBitmap>> {
val context = LocalContext.current
return produceState<Result<ImageBitmap>>(initialValue = Result.Loading, this) {
withIOContext {
value = try {
val appInfo = ExtensionLoader.getExtensionPackageInfoFromPkgName(context, pkgName)!!.applicationInfo!!
val appResources = context.packageManager.getResourcesForApplication(appInfo)
Result.Success(
appResources.getDrawableForDensity(appInfo.icon, density, null)!!
.toBitmap()
.asImageBitmap(),
)
} catch (e: Exception) {
Result.Error
}
}
}
}
sealed class Result<out T> {
data object Loading : Result<Nothing>()
data object Error : Result<Nothing>()
data class Success<out T>(val value: T) : Result<T>()
}
@@ -1,5 +1,8 @@
package eu.kanade.presentation.browse.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ViewList
import androidx.compose.material.icons.filled.ViewModule
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable
@@ -15,10 +18,6 @@ import eu.kanade.presentation.components.RadioMenuItem
import eu.kanade.presentation.components.SearchToolbar
import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.Source
import mihon.icons.materialsymbols.MaterialSymbols
import mihon.icons.materialsymbols.automirroredroundedfilled.ViewList
import mihon.icons.materialsymbols.roundedfilled.ViewCompact
import mihon.icons.materialsymbols.roundedfilled.ViewModule
import tachiyomi.domain.library.model.LibraryDisplayMode
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
@@ -58,12 +57,10 @@ fun BrowseSourceToolbar(
add(
AppBar.Action(
title = stringResource(MR.strings.action_display_mode),
icon = when (displayMode) {
LibraryDisplayMode.List -> MaterialSymbols.AutoMirroredRoundedFilled.ViewList
LibraryDisplayMode.ComfortableGrid -> MaterialSymbols.RoundedFilled.ViewModule
LibraryDisplayMode.CompactGrid,
LibraryDisplayMode.CoverOnlyGrid,
-> MaterialSymbols.RoundedFilled.ViewCompact
icon = if (displayMode == LibraryDisplayMode.List) {
Icons.AutoMirrored.Filled.ViewList
} else {
Icons.Filled.ViewModule
},
onClick = { selectingDisplayMode = true },
),
@@ -1,32 +0,0 @@
package eu.kanade.presentation.browse.components
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.graphics.Color
import dev.icerock.moko.resources.StringResource
import mihon.domain.extension.model.ContentWarning
import tachiyomi.i18n.MR
internal data class ContentWarningLabel(
val title: StringResource,
val description: StringResource,
val color: Color,
)
internal val ContentWarning.label: ContentWarningLabel?
@Composable
@ReadOnlyComposable
get() = when (this) {
ContentWarning.SAFE -> null
ContentWarning.MIXED -> ContentWarningLabel(
title = MR.strings.ext_content_warning_mixed,
description = MR.strings.ext_content_warning_mixed_description,
color = MaterialTheme.colorScheme.primary,
)
ContentWarning.NSFW -> ContentWarningLabel(
title = MR.strings.ext_content_warning_nsfw,
description = MR.strings.ext_content_warning_nsfw_description,
color = MaterialTheme.colorScheme.error,
)
}
@@ -1,32 +0,0 @@
package eu.kanade.presentation.browse.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import tachiyomi.presentation.core.components.material.padding
@Composable
internal fun ExtensionPill(
text: String,
modifier: Modifier = Modifier,
color: Color = MaterialTheme.colorScheme.onSurfaceVariant,
) {
Text(
text = text,
modifier = modifier
.background(color = color.copy(alpha = PILL_BACKGROUND_ALPHA), shape = MaterialTheme.shapes.small)
.padding(horizontal = MaterialTheme.padding.small, vertical = 2.dp),
color = color,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.labelSmall,
)
}
private const val PILL_BACKGROUND_ALPHA = 0.12f
@@ -10,6 +10,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowForward
import androidx.compose.material.icons.outlined.Error
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -20,9 +23,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import mihon.icons.materialsymbols.MaterialSymbols
import mihon.icons.materialsymbols.automirroredrounded.ArrowForward
import mihon.icons.materialsymbols.roundedfilled.Error
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource
@@ -55,7 +55,7 @@ fun GlobalSearchResultItem(
Text(text = subtitle)
}
IconButton(onClick = onClick) {
Icon(imageVector = MaterialSymbols.AutoMirroredRounded.ArrowForward, contentDescription = null)
Icon(imageVector = Icons.AutoMirrored.Outlined.ArrowForward, contentDescription = null)
}
}
content()
@@ -90,7 +90,7 @@ fun GlobalSearchErrorResultItem(message: String?) {
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(imageVector = MaterialSymbols.RoundedFilled.Error, contentDescription = null)
Icon(imageVector = Icons.Outlined.Error, contentDescription = null)
Spacer(Modifier.height(4.dp))
Text(
text = message ?: stringResource(MR.strings.unknown_error),
@@ -7,10 +7,13 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.DoneAll
import androidx.compose.material.icons.outlined.FilterList
import androidx.compose.material.icons.outlined.PushPin
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.HorizontalDivider
@@ -25,10 +28,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import eu.kanade.presentation.components.SearchToolbar
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SourceFilter
import mihon.icons.materialsymbols.MaterialSymbols
import mihon.icons.materialsymbols.rounded.DoneAll
import mihon.icons.materialsymbols.rounded.FilterList
import mihon.icons.materialsymbols.rounded.Keep
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource
@@ -73,7 +72,6 @@ fun GlobalSearchToolbar(
.horizontalScroll(rememberScrollState())
.padding(horizontal = MaterialTheme.padding.small),
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
verticalAlignment = Alignment.CenterVertically,
) {
// TODO: make this UX better; it only applies when triggering a new search
if (!hideSourceFilter) {
@@ -82,7 +80,7 @@ fun GlobalSearchToolbar(
onClick = { onChangeSearchFilter(SourceFilter.PinnedOnly) },
leadingIcon = {
Icon(
imageVector = MaterialSymbols.Rounded.Keep,
imageVector = Icons.Outlined.PushPin,
contentDescription = null,
modifier = Modifier
.size(FilterChipDefaults.IconSize),
@@ -97,7 +95,7 @@ fun GlobalSearchToolbar(
onClick = { onChangeSearchFilter(SourceFilter.All) },
leadingIcon = {
Icon(
imageVector = MaterialSymbols.Rounded.DoneAll,
imageVector = Icons.Outlined.DoneAll,
contentDescription = null,
modifier = Modifier
.size(FilterChipDefaults.IconSize),
@@ -108,7 +106,7 @@ fun GlobalSearchToolbar(
},
)
VerticalDivider(modifier = Modifier.height(FilterChipDefaults.Height))
VerticalDivider()
}
FilterChip(
@@ -116,7 +114,7 @@ fun GlobalSearchToolbar(
onClick = { onToggleResults() },
leadingIcon = {
Icon(
imageVector = MaterialSymbols.Rounded.FilterList,
imageVector = Icons.Outlined.FilterList,
contentDescription = null,
modifier = Modifier
.size(FilterChipDefaults.IconSize),

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