Merge branch 'devel' into FutureProofing-Local-indexes
commit
d6ae67ba07
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AndroidProjectSystem">
|
||||||
|
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@ -1,57 +1,107 @@
|
|||||||
# Kotatsu
|
<div align="center">
|
||||||
|
|
||||||
Kotatsu is a free and open-source manga reader for Android with built-in online content sources.
|
<a href="https://kotatsu.app">
|
||||||
|
<img src="./.github/assets/vtuber.png" alt="Kotatsu Logo" title="Kotatsu" width="600"/>
|
||||||
|
</a>
|
||||||
|
|
||||||
[](https://github.com/KotatsuApp/kotatsu-parsers)   [](https://hosted.weblate.org/engage/kotatsu/) [](https://t.me/kotatsuapp) [](https://discord.gg/NNJ5RgVBC5) [](https://github.com/KotatsuApp/Kotatsu/blob/devel/LICENSE)
|
# [Kotatsu](https://kotatsu.app)
|
||||||
|
|
||||||
|
**[Kotatsu](https://github.com/KotatsuApp/Kotatsu) is a free and open-source manga reader for Android with built-in online content sources.**
|
||||||
|
|
||||||
|
   [](https://github.com/KotatsuApp/kotatsu-parsers) [](https://hosted.weblate.org/engage/kotatsu/) [](https://discord.gg/NNJ5RgVBC5) [](https://t.me/kotatsuapp) [](https://github.com/KotatsuApp/Kotatsu/blob/devel/LICENSE)
|
||||||
|
|
||||||
### Download
|
### Download
|
||||||
|
|
||||||
- **Recommended:** Download and install APK from **[GitHub Releases](https://github.com/KotatsuApp/Kotatsu/releases/latest)**. Application has a built-in self-updating feature.
|
<div align="left">
|
||||||
- Get it on **[F-Droid](https://f-droid.org/packages/org.koitharu.kotatsu)**. The F-Droid build may be a bit outdated and some fixes might be missing.
|
|
||||||
- Also [nightly builds](https://github.com/KotatsuApp/Kotatsu-nightly/releases) are available (very unstable, use at your own risk).
|
* **Recommended:** Download and install APK from [GitHub Releases](https://github.com/KotatsuApp/Kotatsu/releases/latest). Application has a built-in self-updating feature.
|
||||||
|
* Get it on [F-Droid](https://f-droid.org/packages/org.koitharu.kotatsu). The F-Droid build may be a bit outdated and some fixes might be missing.
|
||||||
|
* Also [nightly builds](https://github.com/KotatsuApp/Kotatsu-nightly/releases) are available (very unstable, use at your own risk).
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
### Main Features
|
### Main Features
|
||||||
|
|
||||||
* Online [manga catalogues](https://github.com/KotatsuApp/kotatsu-parsers)
|
<div align="left">
|
||||||
|
|
||||||
|
* Online [manga catalogues](https://github.com/KotatsuApp/kotatsu-parsers) (with 1100+ manga sources)
|
||||||
* Search manga by name, genres, and more filters
|
* Search manga by name, genres, and more filters
|
||||||
* Reading history and bookmarks
|
|
||||||
* Favorites organized by user-defined categories
|
* Favorites organized by user-defined categories
|
||||||
* Downloading manga and reading it offline. Third-party CBZ archives also supported
|
* Reading history, bookmarks, and incognito mode support
|
||||||
* Tablet-optimized Material You UI
|
* Download manga and read it offline. Third-party CBZ archives are also supported
|
||||||
* Standard and Webtoon-optimized customizable reader
|
* Clean and convenient Material You UI, optimized for phones, tablets, and desktop
|
||||||
* Notifications about new chapters with updates feed
|
* Standard and Webtoon-optimized customizable reader, gesture support on reading interface
|
||||||
|
* Notifications about new chapters with updates feed, manga recommendations (with filters)
|
||||||
* Integration with manga tracking services: Shikimori, AniList, MyAnimeList, Kitsu
|
* Integration with manga tracking services: Shikimori, AniList, MyAnimeList, Kitsu
|
||||||
* Password/fingerprint-protected access to the app
|
* Password / fingerprint-protected access to the app
|
||||||
|
* Automatically sync app data with other devices on the same account
|
||||||
|
* Support for older devices running Android 5+
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
### In-App Screenshots
|
||||||
|
|
||||||
### Screenshots
|
<div align="center">
|
||||||
|
<img src="./metadata/en-US/images/phoneScreenshots/1.png" alt="Mobile view" width="250"/>
|
||||||
|
<img src="./metadata/en-US/images/phoneScreenshots/2.png" alt="Mobile view" width="250"/>
|
||||||
|
<img src="./metadata/en-US/images/phoneScreenshots/3.png" alt="Mobile view" width="250"/>
|
||||||
|
<img src="./metadata/en-US/images/phoneScreenshots/4.png" alt="Mobile view" width="250"/>
|
||||||
|
<img src="./metadata/en-US/images/phoneScreenshots/5.png" alt="Mobile view" width="250"/>
|
||||||
|
<img src="./metadata/en-US/images/phoneScreenshots/6.png" alt="Mobile view" width="250"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|  |  |  |
|
<br>
|
||||||
|-----------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|
|
|
||||||
|  |  |  |
|
|
||||||
|
|
||||||
|  |  |
|
<div align="center">
|
||||||
|-----------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------|
|
<img src="./metadata/en-US/images/tenInchScreenshots/1.png" alt="Tablet view" width="400"/>
|
||||||
|
<img src="./metadata/en-US/images/tenInchScreenshots/2.png" alt="Tablet view" width="400"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
### Localization
|
### Localization
|
||||||
|
|
||||||
[<img src="https://hosted.weblate.org/widgets/kotatsu/-/287x66-white.png" alt="Translation status">](https://hosted.weblate.org/engage/kotatsu/)
|
<a href="https://hosted.weblate.org/engage/kotatsu/">
|
||||||
|
<img src="https://hosted.weblate.org/widget/kotatsu/horizontal-auto.png" alt="Translation status" />
|
||||||
|
</a>
|
||||||
|
|
||||||
Kotatsu is localized in a number of different languages, if you would like to help improve these or add new languages,
|
**[Kotatsu](https://github.com/KotatsuApp/Kotatsu) is localized in a number of different languages.**<br>
|
||||||
please head over to the [Weblate project page](https://hosted.weblate.org/engage/kotatsu/)
|
**📌 If you would like to help improve these or add new languages,
|
||||||
|
please head over to the [Weblate project page](https://hosted.weblate.org/engage/kotatsu/)**
|
||||||
|
|
||||||
### Contributing
|
### Contributing
|
||||||
|
|
||||||
See [CONTRIBUTING.md](./CONTRIBUTING.md) for the guidelines.
|
<br>
|
||||||
|
|
||||||
|
<a href="https://github.com/KotatsuApp/Kotatsu">
|
||||||
|
<picture>
|
||||||
|
<source srcset="https://github-readme-stats.vercel.app/api/pin/?username=KotatsuApp&repo=Kotatsu&bg_color=0d1117&text_color=1976d2&title_color=1976d2&icon_color=0877d2&border_radius=10&description_lines_count=2&show_owner=true" media="(prefers-color-scheme: dark)">
|
||||||
|
<img src="https://github-readme-stats.vercel.app/api/pin/?username=KotatsuApp&repo=Kotatsu&text_color=1976d2&title_color=1976d2&icon_color=0877d2&border_radius=10&description_lines_count=2&show_owner=true" alt="Kotatsu GitHub Repository">
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/KotatsuApp/Kotatsu-parsers">
|
||||||
|
<picture>
|
||||||
|
<source srcset="https://github-readme-stats.vercel.app/api/pin/?username=KotatsuApp&repo=Kotatsu-parsers&bg_color=0d1117&text_color=1976d2&title_color=1976d2&icon_color=0877d2&border_radius=10&description_lines_count=2&show_owner=true" media="(prefers-color-scheme: dark)">
|
||||||
|
<img src="https://github-readme-stats.vercel.app/api/pin/?username=KotatsuApp&repo=Kotatsu-parsers&text_color=1976d2&title_color=1976d2&icon_color=0877d2&border_radius=10&description_lines_count=2&show_owner=true" alt="Kotatsu-parsers GitHub Repository">
|
||||||
|
</picture>
|
||||||
|
</a><br></br>
|
||||||
|
|
||||||
|
</br>
|
||||||
|
|
||||||
|
**📌 Pull requests are welcome, if you want: See [CONTRIBUTING.md](https://github.com/KotatsuApp/Kotatsu/blob/devel/CONTRIBUTING.md) for the guidelines**
|
||||||
|
|
||||||
### License
|
### License
|
||||||
|
|
||||||
[](http://www.gnu.org/licenses/gpl-3.0.en.html)
|
[](http://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||||
|
|
||||||
You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications
|
<div align="left">
|
||||||
to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build &
|
|
||||||
install instructions.
|
You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build & install instructions.
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
### DMCA disclaimer
|
### DMCA disclaimer
|
||||||
|
|
||||||
The developers of this application do not have any affiliation with the content available in the app.
|
<div align="left">
|
||||||
It collects content from sources that are freely available through any web browser
|
|
||||||
|
The developers of this application do not have any affiliation with the content available in the app. It collects content from sources that are freely available through any web browser.
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|||||||
@ -0,0 +1,15 @@
|
|||||||
|
package org.koitharu.kotatsu.core.ui
|
||||||
|
|
||||||
|
import androidx.lifecycle.LifecycleService
|
||||||
|
import leakcanary.AppWatcher
|
||||||
|
|
||||||
|
abstract class BaseService : LifecycleService() {
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
AppWatcher.objectWatcher.watch(
|
||||||
|
watchedObject = this,
|
||||||
|
description = "${javaClass.simpleName} service received Service#onDestroy() callback",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,93 @@
|
|||||||
|
package org.koitharu.kotatsu.core.backup
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.annotation.CheckResult
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||||
|
import okhttp3.MultipartBody
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.asRequestBody
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.internal.closeQuietly
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||||
|
import org.koitharu.kotatsu.core.network.BaseHttpClient
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.parsers.util.await
|
||||||
|
import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault
|
||||||
|
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
|
||||||
|
import org.koitharu.kotatsu.parsers.util.parseJson
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class TelegramBackupUploader @Inject constructor(
|
||||||
|
private val settings: AppSettings,
|
||||||
|
@BaseHttpClient private val client: OkHttpClient,
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val botToken = context.getString(R.string.tg_backup_bot_token)
|
||||||
|
|
||||||
|
suspend fun uploadBackup(file: File) {
|
||||||
|
val requestBody = file.asRequestBody("application/zip".toMediaTypeOrNull())
|
||||||
|
val multipartBody = MultipartBody.Builder()
|
||||||
|
.setType(MultipartBody.FORM)
|
||||||
|
.addFormDataPart("chat_id", requireChatId())
|
||||||
|
.addFormDataPart("document", file.name, requestBody)
|
||||||
|
.build()
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(urlOf("sendDocument").build())
|
||||||
|
.post(multipartBody)
|
||||||
|
.build()
|
||||||
|
client.newCall(request).await().consume()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun sendTestMessage() {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(urlOf("getMe").build())
|
||||||
|
.build()
|
||||||
|
client.newCall(request).await().consume()
|
||||||
|
sendMessage(context.getString(R.string.backup_tg_echo))
|
||||||
|
}
|
||||||
|
|
||||||
|
@CheckResult
|
||||||
|
fun openBotInApp(router: AppRouter): Boolean {
|
||||||
|
val botUsername = context.getString(R.string.tg_backup_bot_name)
|
||||||
|
return router.openExternalBrowser("tg://resolve?domain=$botUsername") ||
|
||||||
|
router.openExternalBrowser("https://t.me/$botUsername")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun sendMessage(message: String) {
|
||||||
|
val url = urlOf("sendMessage")
|
||||||
|
.addQueryParameter("chat_id", requireChatId())
|
||||||
|
.addQueryParameter("text", message)
|
||||||
|
.build()
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.build()
|
||||||
|
client.newCall(request).await().consume()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requireChatId() = checkNotNull(settings.backupTelegramChatId) {
|
||||||
|
"Telegram chat ID not set in settings"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Response.consume() {
|
||||||
|
if (isSuccessful) {
|
||||||
|
closeQuietly()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val jo = parseJson()
|
||||||
|
if (!jo.getBooleanOrDefault("ok", true)) {
|
||||||
|
throw RuntimeException(jo.getStringOrNull("description"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun urlOf(method: String) = HttpUrl.Builder()
|
||||||
|
.scheme("https")
|
||||||
|
.host("api.telegram.org")
|
||||||
|
.addPathSegment("bot$botToken")
|
||||||
|
.addPathSegment(method)
|
||||||
|
}
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
package org.koitharu.kotatsu.core.db.dao
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Transaction
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.ChapterEntity
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
abstract class ChaptersDao {
|
||||||
|
|
||||||
|
@Query("SELECT * FROM chapters WHERE manga_id = :mangaId ORDER BY `index` ASC")
|
||||||
|
abstract suspend fun findAll(mangaId: Long): List<ChapterEntity>
|
||||||
|
|
||||||
|
@Query("DELETE FROM chapters WHERE manga_id = :mangaId")
|
||||||
|
abstract suspend fun deleteAll(mangaId: Long)
|
||||||
|
|
||||||
|
@Query("DELETE FROM chapters WHERE manga_id NOT IN (SELECT manga_id FROM history WHERE deleted_at = 0) AND manga_id NOT IN (SELECT manga_id FROM favourites WHERE deleted_at = 0)")
|
||||||
|
abstract suspend fun gc()
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
open suspend fun replaceAll(mangaId: Long, entities: Collection<ChapterEntity>) {
|
||||||
|
deleteAll(mangaId)
|
||||||
|
insert(entities)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
protected abstract suspend fun insert(entities: Collection<ChapterEntity>)
|
||||||
|
}
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
package org.koitharu.kotatsu.core.db.entity
|
||||||
|
|
||||||
|
import androidx.room.ColumnInfo
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.ForeignKey
|
||||||
|
import org.koitharu.kotatsu.core.db.TABLE_CHAPTERS
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = TABLE_CHAPTERS,
|
||||||
|
primaryKeys = ["manga_id", "chapter_id"],
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = MangaEntity::class,
|
||||||
|
parentColumns = ["manga_id"],
|
||||||
|
childColumns = ["manga_id"],
|
||||||
|
onDelete = ForeignKey.CASCADE,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
data class ChapterEntity(
|
||||||
|
@ColumnInfo(name = "chapter_id") val chapterId: Long,
|
||||||
|
@ColumnInfo(name = "manga_id") val mangaId: Long,
|
||||||
|
@ColumnInfo(name = "name") val name: String,
|
||||||
|
@ColumnInfo(name = "number") val number: Float,
|
||||||
|
@ColumnInfo(name = "volume") val volume: Int,
|
||||||
|
@ColumnInfo(name = "url") val url: String,
|
||||||
|
@ColumnInfo(name = "scanlator") val scanlator: String?,
|
||||||
|
@ColumnInfo(name = "upload_date") val uploadDate: Long,
|
||||||
|
@ColumnInfo(name = "branch") val branch: String?,
|
||||||
|
@ColumnInfo(name = "source") val source: String,
|
||||||
|
@ColumnInfo(name = "index") val index: Int,
|
||||||
|
)
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
package org.koitharu.kotatsu.core.db.migrations
|
||||||
|
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
|
||||||
|
class Migration23To24 : Migration(23, 24) {
|
||||||
|
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("CREATE TABLE IF NOT EXISTS `chapters` (`chapter_id` INTEGER NOT NULL, `manga_id` INTEGER NOT NULL, `name` TEXT NOT NULL, `number` REAL NOT NULL, `volume` INTEGER NOT NULL, `url` TEXT NOT NULL, `scanlator` TEXT, `upload_date` INTEGER NOT NULL, `branch` TEXT, `source` TEXT NOT NULL, `index` INTEGER NOT NULL, PRIMARY KEY(`manga_id`, `chapter_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
package org.koitharu.kotatsu.core.model
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||||
|
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
||||||
|
|
||||||
|
fun ListFilterOption.toChipModel(isChecked: Boolean) = ChipsView.ChipModel(
|
||||||
|
title = titleText,
|
||||||
|
titleResId = titleResId,
|
||||||
|
icon = iconResId,
|
||||||
|
iconData = getIconData(),
|
||||||
|
isChecked = isChecked,
|
||||||
|
data = this,
|
||||||
|
)
|
||||||
@ -0,0 +1,624 @@
|
|||||||
|
package org.koitharu.kotatsu.core.nav
|
||||||
|
|
||||||
|
import android.accounts.Account
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.view.View
|
||||||
|
import androidx.annotation.CheckResult
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.fragment.app.DialogFragment
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import androidx.fragment.app.FragmentManager
|
||||||
|
import androidx.fragment.app.findFragment
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
|
import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity
|
||||||
|
import org.koitharu.kotatsu.bookmarks.ui.AllBookmarksActivity
|
||||||
|
import org.koitharu.kotatsu.browser.BrowserActivity
|
||||||
|
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||||
|
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||||
|
import org.koitharu.kotatsu.core.model.MangaSourceInfo
|
||||||
|
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||||
|
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaListFilter
|
||||||
|
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaPage
|
||||||
|
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||||
|
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
|
||||||
|
import org.koitharu.kotatsu.core.prefs.ReaderMode
|
||||||
|
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.findActivity
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||||
|
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||||
|
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet
|
||||||
|
import org.koitharu.kotatsu.details.ui.related.RelatedMangaActivity
|
||||||
|
import org.koitharu.kotatsu.details.ui.scrobbling.ScrobblingInfoSheet
|
||||||
|
import org.koitharu.kotatsu.download.ui.dialog.DownloadDialogFragment
|
||||||
|
import org.koitharu.kotatsu.download.ui.list.DownloadsActivity
|
||||||
|
import org.koitharu.kotatsu.favourites.ui.FavouritesActivity
|
||||||
|
import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity
|
||||||
|
import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity
|
||||||
|
import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteDialog
|
||||||
|
import org.koitharu.kotatsu.filter.ui.FilterCoordinator
|
||||||
|
import org.koitharu.kotatsu.filter.ui.sheet.FilterSheetFragment
|
||||||
|
import org.koitharu.kotatsu.filter.ui.tags.TagsCatalogSheet
|
||||||
|
import org.koitharu.kotatsu.history.ui.HistoryActivity
|
||||||
|
import org.koitharu.kotatsu.image.ui.ImageActivity
|
||||||
|
import org.koitharu.kotatsu.list.ui.config.ListConfigBottomSheet
|
||||||
|
import org.koitharu.kotatsu.list.ui.config.ListConfigSection
|
||||||
|
import org.koitharu.kotatsu.local.ui.ImportDialogFragment
|
||||||
|
import org.koitharu.kotatsu.local.ui.info.LocalInfoDialog
|
||||||
|
import org.koitharu.kotatsu.main.ui.MainActivity
|
||||||
|
import org.koitharu.kotatsu.main.ui.welcome.WelcomeSheet
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||||
|
import org.koitharu.kotatsu.parsers.util.isNullOrEmpty
|
||||||
|
import org.koitharu.kotatsu.parsers.util.mapToArray
|
||||||
|
import org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity
|
||||||
|
import org.koitharu.kotatsu.reader.ui.config.ReaderConfigSheet
|
||||||
|
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
|
||||||
|
import org.koitharu.kotatsu.scrobbling.common.ui.config.ScrobblerConfigActivity
|
||||||
|
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
|
||||||
|
import org.koitharu.kotatsu.search.ui.MangaListActivity
|
||||||
|
import org.koitharu.kotatsu.search.ui.multi.SearchActivity
|
||||||
|
import org.koitharu.kotatsu.settings.SettingsActivity
|
||||||
|
import org.koitharu.kotatsu.settings.about.AppUpdateActivity
|
||||||
|
import org.koitharu.kotatsu.settings.backup.BackupDialogFragment
|
||||||
|
import org.koitharu.kotatsu.settings.backup.RestoreDialogFragment
|
||||||
|
import org.koitharu.kotatsu.settings.reader.ReaderTapGridConfigActivity
|
||||||
|
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
|
||||||
|
import org.koitharu.kotatsu.settings.sources.catalog.SourcesCatalogActivity
|
||||||
|
import org.koitharu.kotatsu.settings.storage.MangaDirectorySelectDialog
|
||||||
|
import org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity
|
||||||
|
import org.koitharu.kotatsu.settings.tracker.categories.TrackerCategoriesConfigSheet
|
||||||
|
import org.koitharu.kotatsu.stats.ui.StatsActivity
|
||||||
|
import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet
|
||||||
|
import org.koitharu.kotatsu.suggestions.ui.SuggestionsActivity
|
||||||
|
import org.koitharu.kotatsu.tracker.ui.updates.UpdatesActivity
|
||||||
|
|
||||||
|
class AppRouter private constructor(
|
||||||
|
private val activity: FragmentActivity?,
|
||||||
|
private val fragment: Fragment?,
|
||||||
|
) {
|
||||||
|
|
||||||
|
constructor(activity: FragmentActivity) : this(activity, null)
|
||||||
|
|
||||||
|
constructor(fragment: Fragment) : this(null, fragment)
|
||||||
|
|
||||||
|
/** Activities **/
|
||||||
|
|
||||||
|
fun openList(source: MangaSource, filter: MangaListFilter?) {
|
||||||
|
startActivity(listIntent(contextOrNull() ?: return, source, filter))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openList(tag: MangaTag) = openList(tag.source, MangaListFilter(tags = setOf(tag)))
|
||||||
|
|
||||||
|
fun openSearch(query: String) {
|
||||||
|
startActivity(
|
||||||
|
Intent(contextOrNull() ?: return, SearchActivity::class.java)
|
||||||
|
.putExtra(KEY_QUERY, query),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openSearch(source: MangaSource, query: String) = openList(source, MangaListFilter(query = query))
|
||||||
|
|
||||||
|
fun openDetails(manga: Manga) {
|
||||||
|
startActivity(detailsIntent(contextOrNull() ?: return, manga))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openDetails(mangaId: Long) {
|
||||||
|
startActivity(detailsIntent(contextOrNull() ?: return, mangaId))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openReader(manga: Manga, anchor: View? = null) {
|
||||||
|
openReader(
|
||||||
|
ReaderIntent.Builder(contextOrNull() ?: return)
|
||||||
|
.manga(manga)
|
||||||
|
.build(),
|
||||||
|
anchor,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openReader(intent: ReaderIntent, anchor: View? = null) {
|
||||||
|
startActivity(intent.intent, anchor?.let { view -> scaleUpActivityOptionsOf(view) })
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openAlternatives(manga: Manga) {
|
||||||
|
startActivity(
|
||||||
|
Intent(contextOrNull() ?: return, AlternativesActivity::class.java)
|
||||||
|
.putExtra(KEY_MANGA, ParcelableManga(manga)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openRelated(manga: Manga) {
|
||||||
|
startActivity(
|
||||||
|
Intent(contextOrNull(), RelatedMangaActivity::class.java)
|
||||||
|
.putExtra(KEY_MANGA, ParcelableManga(manga)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openImage(url: String, source: MangaSource?, anchor: View? = null) {
|
||||||
|
startActivity(
|
||||||
|
Intent(contextOrNull(), ImageActivity::class.java)
|
||||||
|
.setData(url.toUri())
|
||||||
|
.putExtra(KEY_SOURCE, source?.name),
|
||||||
|
anchor?.let { scaleUpActivityOptionsOf(it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openBookmarks() = startActivity(AllBookmarksActivity::class.java)
|
||||||
|
|
||||||
|
fun openAppUpdate() = startActivity(AppUpdateActivity::class.java)
|
||||||
|
|
||||||
|
fun openSuggestions() {
|
||||||
|
startActivity(suggestionsIntent(contextOrNull() ?: return))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openSourcesCatalog() = startActivity(SourcesCatalogActivity::class.java)
|
||||||
|
|
||||||
|
fun openDownloads() = startActivity(DownloadsActivity::class.java)
|
||||||
|
|
||||||
|
fun openDirectoriesSettings() = startActivity(MangaDirectoriesActivity::class.java)
|
||||||
|
|
||||||
|
fun openBrowser(url: String, source: MangaSource?, title: String?) {
|
||||||
|
startActivity(
|
||||||
|
Intent(contextOrNull() ?: return, BrowserActivity::class.java)
|
||||||
|
.setData(url.toUri())
|
||||||
|
.putExtra(KEY_TITLE, title)
|
||||||
|
.putExtra(KEY_SOURCE, source?.name),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openColorFilterConfig(manga: Manga, page: MangaPage) {
|
||||||
|
startActivity(
|
||||||
|
Intent(contextOrNull(), ColorFilterConfigActivity::class.java)
|
||||||
|
.putExtra(KEY_MANGA, ParcelableManga(manga))
|
||||||
|
.putExtra(KEY_PAGES, ParcelableMangaPage(page)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openHistory() = startActivity(HistoryActivity::class.java)
|
||||||
|
|
||||||
|
fun openFavorites() = startActivity(FavouritesActivity::class.java)
|
||||||
|
|
||||||
|
fun openFavorites(category: FavouriteCategory) {
|
||||||
|
startActivity(
|
||||||
|
Intent(contextOrNull() ?: return, FavouritesActivity::class.java)
|
||||||
|
.putExtra(KEY_ID, category.id)
|
||||||
|
.putExtra(KEY_TITLE, category.title),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openFavoriteCategories() = startActivity(FavouriteCategoriesActivity::class.java)
|
||||||
|
|
||||||
|
fun openFavoriteCategoryEdit(categoryId: Long) {
|
||||||
|
startActivity(
|
||||||
|
Intent(contextOrNull() ?: return, FavouritesCategoryEditActivity::class.java)
|
||||||
|
.putExtra(KEY_ID, categoryId),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openFavoriteCategoryCreate() = openFavoriteCategoryEdit(FavouritesCategoryEditActivity.NO_ID)
|
||||||
|
|
||||||
|
fun openMangaUpdates() {
|
||||||
|
startActivity(mangaUpdatesIntent(contextOrNull() ?: return))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openSettings() = startActivity(SettingsActivity::class.java)
|
||||||
|
|
||||||
|
fun openReaderSettings() {
|
||||||
|
startActivity(readerSettingsIntent(contextOrNull() ?: return))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openProxySettings() {
|
||||||
|
startActivity(proxySettingsIntent(contextOrNull() ?: return))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openDownloadsSetting() {
|
||||||
|
startActivity(downloadsSettingsIntent(contextOrNull() ?: return))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openSourceSettings(source: MangaSource) {
|
||||||
|
startActivity(sourceSettingsIntent(contextOrNull() ?: return, source))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openSuggestionsSettings() {
|
||||||
|
startActivity(suggestionsSettingsIntent(contextOrNull() ?: return))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openSourcesSettings() {
|
||||||
|
startActivity(sourcesSettingsIntent(contextOrNull() ?: return))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openReaderTapGridSettings() = startActivity(ReaderTapGridConfigActivity::class.java)
|
||||||
|
|
||||||
|
fun openScrobblerSettings(scrobbler: ScrobblerService) {
|
||||||
|
startActivity(
|
||||||
|
Intent(contextOrNull() ?: return, ScrobblerConfigActivity::class.java)
|
||||||
|
.putExtra(KEY_ID, scrobbler.id),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openSourceAuth(source: MangaSource) {
|
||||||
|
startActivity(sourceAuthIntent(contextOrNull() ?: return, source))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openManageSources() {
|
||||||
|
startActivity(
|
||||||
|
manageSourcesIntent(contextOrNull() ?: return),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openStatistic() = startActivity(StatsActivity::class.java)
|
||||||
|
|
||||||
|
@CheckResult
|
||||||
|
fun openExternalBrowser(url: String, chooserTitle: CharSequence? = null): Boolean {
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW)
|
||||||
|
intent.data = url.toUriOrNull() ?: return false
|
||||||
|
return startActivitySafe(
|
||||||
|
if (!chooserTitle.isNullOrEmpty()) {
|
||||||
|
Intent.createChooser(intent, chooserTitle)
|
||||||
|
} else {
|
||||||
|
intent
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@CheckResult
|
||||||
|
fun openSystemSyncSettings(account: Account): Boolean {
|
||||||
|
val args = Bundle(1)
|
||||||
|
args.putParcelable(ACCOUNT_KEY, account)
|
||||||
|
val intent = Intent(ACTION_ACCOUNT_SYNC_SETTINGS)
|
||||||
|
intent.putExtra(EXTRA_SHOW_FRAGMENT_ARGUMENTS, args)
|
||||||
|
return startActivitySafe(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dialogs **/
|
||||||
|
|
||||||
|
fun showDownloadDialog(manga: Manga, snackbarHost: View?) = showDownloadDialog(setOf(manga), snackbarHost)
|
||||||
|
|
||||||
|
fun showDownloadDialog(manga: Collection<Manga>, snackbarHost: View?) {
|
||||||
|
if (manga.isEmpty()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val fm = getFragmentManager() ?: return
|
||||||
|
if (snackbarHost != null) {
|
||||||
|
getLifecycleOwner()?.let { lifecycleOwner ->
|
||||||
|
DownloadDialogFragment.registerCallback(fm, lifecycleOwner, snackbarHost)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
DownloadDialogFragment.unregisterCallback(fm)
|
||||||
|
}
|
||||||
|
DownloadDialogFragment().withArgs(1) {
|
||||||
|
putParcelableArray(KEY_MANGA, manga.mapToArray { ParcelableManga(it, withDescription = false) })
|
||||||
|
}.showDistinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showLocalInfoDialog(manga: Manga) {
|
||||||
|
LocalInfoDialog().withArgs(1) {
|
||||||
|
putParcelable(KEY_MANGA, ParcelableManga(manga))
|
||||||
|
}.showDistinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showDirectorySelectDialog() {
|
||||||
|
MangaDirectorySelectDialog().showDistinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showFavoriteDialog(manga: Manga) = showFavoriteDialog(setOf(manga))
|
||||||
|
|
||||||
|
fun showFavoriteDialog(manga: Collection<Manga>) {
|
||||||
|
if (manga.isEmpty()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
FavoriteDialog().withArgs(1) {
|
||||||
|
putParcelableArrayList(
|
||||||
|
KEY_MANGA_LIST,
|
||||||
|
manga.mapTo(ArrayList(manga.size)) { ParcelableManga(it, withDescription = false) },
|
||||||
|
)
|
||||||
|
}.showDistinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showErrorDialog(error: Throwable, url: String? = null) {
|
||||||
|
ErrorDetailsDialog().withArgs(2) {
|
||||||
|
putSerializable(KEY_ERROR, error)
|
||||||
|
putString(KEY_URL, url)
|
||||||
|
}.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showBackupRestoreDialog(fileUri: Uri) {
|
||||||
|
RestoreDialogFragment().withArgs(1) {
|
||||||
|
putString(KEY_FILE, fileUri.toString())
|
||||||
|
}.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showBackupCreateDialog() {
|
||||||
|
BackupDialogFragment().show()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showImportDialog() {
|
||||||
|
ImportDialogFragment().showDistinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showFilterSheet(): Boolean = if (isFilterSupported()) {
|
||||||
|
FilterSheetFragment().showDistinct()
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showTagsCatalogSheet(excludeMode: Boolean) {
|
||||||
|
if (!isFilterSupported()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
TagsCatalogSheet().withArgs(1) {
|
||||||
|
putBoolean(KEY_EXCLUDE, excludeMode)
|
||||||
|
}.showDistinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showListConfigSheet(section: ListConfigSection) {
|
||||||
|
ListConfigBottomSheet().withArgs(1) {
|
||||||
|
putParcelable(KEY_LIST_SECTION, section)
|
||||||
|
}.showDistinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showStatisticSheet(manga: Manga) {
|
||||||
|
MangaStatsSheet().withArgs(1) {
|
||||||
|
putParcelable(KEY_MANGA, ParcelableManga(manga))
|
||||||
|
}.showDistinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showReaderConfigSheet(mode: ReaderMode) {
|
||||||
|
ReaderConfigSheet().withArgs(1) {
|
||||||
|
putInt(KEY_READER_MODE, mode.id)
|
||||||
|
}.showDistinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showWelcomeSheet() {
|
||||||
|
WelcomeSheet().showDistinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showChapterPagesSheet() {
|
||||||
|
ChaptersPagesSheet().showDistinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showChapterPagesSheet(defaultTab: Int) {
|
||||||
|
ChaptersPagesSheet().withArgs(1) {
|
||||||
|
putInt(KEY_TAB, defaultTab)
|
||||||
|
}.showDistinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showScrobblingSelectorSheet(manga: Manga, scrobblerService: ScrobblerService?) {
|
||||||
|
ScrobblingSelectorSheet().withArgs(2) {
|
||||||
|
putParcelable(KEY_MANGA, ParcelableManga(manga))
|
||||||
|
if (scrobblerService != null) {
|
||||||
|
putInt(KEY_ID, scrobblerService.id)
|
||||||
|
}
|
||||||
|
}.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showScrobblingInfoSheet(index: Int) {
|
||||||
|
ScrobblingInfoSheet().withArgs(1) {
|
||||||
|
putInt(KEY_INDEX, index)
|
||||||
|
}.showDistinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showTrackerCategoriesConfigSheet() {
|
||||||
|
TrackerCategoriesConfigSheet().showDistinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public utils **/
|
||||||
|
|
||||||
|
fun isFilterSupported(): Boolean = when {
|
||||||
|
fragment != null -> fragment.activity is FilterCoordinator.Owner
|
||||||
|
activity != null -> activity is FilterCoordinator.Owner
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isChapterPagesSheetShown(): Boolean {
|
||||||
|
val sheet = getFragmentManager()?.findFragmentByTag(fragmentTag<ChaptersPagesSheet>()) as? ChaptersPagesSheet
|
||||||
|
return sheet?.dialog?.isShowing == true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun closeWelcomeSheet(): Boolean {
|
||||||
|
val tag = fragmentTag<WelcomeSheet>()
|
||||||
|
val sheet = fragment?.findFragmentByTagRecursive(tag)
|
||||||
|
?: activity?.supportFragmentManager?.findFragmentByTag(tag)
|
||||||
|
?: return false
|
||||||
|
return if (sheet is WelcomeSheet) {
|
||||||
|
sheet.dismissAllowingStateLoss()
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Private utils **/
|
||||||
|
|
||||||
|
private fun startActivity(intent: Intent, options: Bundle? = null) {
|
||||||
|
fragment?.startActivity(intent, options)
|
||||||
|
?: activity?.startActivity(intent, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startActivitySafe(intent: Intent): Boolean = try {
|
||||||
|
startActivity(intent)
|
||||||
|
true
|
||||||
|
} catch (_: ActivityNotFoundException) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startActivity(activityClass: Class<out Activity>) {
|
||||||
|
startActivity(Intent(contextOrNull() ?: return, activityClass))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getFragmentManager(): FragmentManager? {
|
||||||
|
return fragment?.childFragmentManager ?: activity?.supportFragmentManager
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun contextOrNull(): Context? = activity ?: fragment?.context
|
||||||
|
|
||||||
|
private fun getLifecycleOwner(): LifecycleOwner? = activity ?: fragment?.viewLifecycleOwner
|
||||||
|
|
||||||
|
private fun DialogFragment.showDistinct(): Boolean {
|
||||||
|
val fm = this@AppRouter.getFragmentManager() ?: return false
|
||||||
|
val tag = javaClass.fragmentTag()
|
||||||
|
val existing = fm.findFragmentByTag(tag) as? DialogFragment?
|
||||||
|
if (existing != null && existing.isVisible && existing.arguments == this.arguments) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
show(fm, tag)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DialogFragment.show() {
|
||||||
|
show(
|
||||||
|
this@AppRouter.getFragmentManager() ?: return,
|
||||||
|
javaClass.fragmentTag(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Fragment.findFragmentByTagRecursive(fragmentTag: String): Fragment? {
|
||||||
|
childFragmentManager.findFragmentByTag(fragmentTag)?.let {
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
val parent = parentFragment
|
||||||
|
return if (parent != null) {
|
||||||
|
parent.findFragmentByTagRecursive(fragmentTag)
|
||||||
|
} else {
|
||||||
|
parentFragmentManager.findFragmentByTag(fragmentTag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
fun from(view: View): AppRouter? = runCatching {
|
||||||
|
AppRouter(view.findFragment<Fragment>())
|
||||||
|
}.getOrElse {
|
||||||
|
(view.context.findActivity() as? FragmentActivity)?.let(::AppRouter)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun detailsIntent(context: Context, manga: Manga) = Intent(context, DetailsActivity::class.java)
|
||||||
|
.putExtra(KEY_MANGA, ParcelableManga(manga))
|
||||||
|
|
||||||
|
fun detailsIntent(context: Context, mangaId: Long) = Intent(context, DetailsActivity::class.java)
|
||||||
|
.putExtra(KEY_ID, mangaId)
|
||||||
|
|
||||||
|
fun listIntent(context: Context, source: MangaSource, filter: MangaListFilter?): Intent =
|
||||||
|
Intent(context, MangaListActivity::class.java)
|
||||||
|
.setAction(ACTION_MANGA_EXPLORE)
|
||||||
|
.putExtra(KEY_SOURCE, source.name)
|
||||||
|
.apply {
|
||||||
|
if (!filter.isNullOrEmpty()) {
|
||||||
|
putExtra(KEY_FILTER, ParcelableMangaListFilter(filter))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cloudFlareResolveIntent(context: Context, exception: CloudFlareProtectedException): Intent =
|
||||||
|
Intent(context, CloudFlareActivity::class.java).apply {
|
||||||
|
data = exception.url.toUri()
|
||||||
|
putExtra(KEY_SOURCE, exception.source?.name)
|
||||||
|
exception.headers.get(CommonHeaders.USER_AGENT)?.let {
|
||||||
|
putExtra(KEY_USER_AGENT, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun suggestionsIntent(context: Context) = Intent(context, SuggestionsActivity::class.java)
|
||||||
|
|
||||||
|
fun homeIntent(context: Context) = Intent(context, MainActivity::class.java)
|
||||||
|
|
||||||
|
fun mangaUpdatesIntent(context: Context) = Intent(context, UpdatesActivity::class.java)
|
||||||
|
|
||||||
|
fun readerSettingsIntent(context: Context) =
|
||||||
|
Intent(context, SettingsActivity::class.java)
|
||||||
|
.setAction(ACTION_READER)
|
||||||
|
|
||||||
|
fun suggestionsSettingsIntent(context: Context) =
|
||||||
|
Intent(context, SettingsActivity::class.java)
|
||||||
|
.setAction(ACTION_SUGGESTIONS)
|
||||||
|
|
||||||
|
fun trackerSettingsIntent(context: Context) =
|
||||||
|
Intent(context, SettingsActivity::class.java)
|
||||||
|
.setAction(ACTION_TRACKER)
|
||||||
|
|
||||||
|
fun proxySettingsIntent(context: Context) =
|
||||||
|
Intent(context, SettingsActivity::class.java)
|
||||||
|
.setAction(ACTION_PROXY)
|
||||||
|
|
||||||
|
fun historySettingsIntent(context: Context) =
|
||||||
|
Intent(context, SettingsActivity::class.java)
|
||||||
|
.setAction(ACTION_HISTORY)
|
||||||
|
|
||||||
|
fun sourcesSettingsIntent(context: Context) =
|
||||||
|
Intent(context, SettingsActivity::class.java)
|
||||||
|
.setAction(ACTION_SOURCES)
|
||||||
|
|
||||||
|
fun manageSourcesIntent(context: Context) =
|
||||||
|
Intent(context, SettingsActivity::class.java)
|
||||||
|
.setAction(ACTION_MANAGE_SOURCES)
|
||||||
|
|
||||||
|
fun downloadsSettingsIntent(context: Context) =
|
||||||
|
Intent(context, SettingsActivity::class.java)
|
||||||
|
.setAction(ACTION_MANAGE_DOWNLOADS)
|
||||||
|
|
||||||
|
fun sourceSettingsIntent(context: Context, source: MangaSource): Intent = when (source) {
|
||||||
|
is MangaSourceInfo -> sourceSettingsIntent(context, source.mangaSource)
|
||||||
|
is ExternalMangaSource -> Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
|
||||||
|
.setData(Uri.fromParts("package", source.packageName, null))
|
||||||
|
|
||||||
|
else -> Intent(context, SettingsActivity::class.java)
|
||||||
|
.setAction(ACTION_SOURCE)
|
||||||
|
.putExtra(KEY_SOURCE, source.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sourceAuthIntent(context: Context, source: MangaSource): Intent {
|
||||||
|
return Intent(context, SourceAuthActivity::class.java)
|
||||||
|
.putExtra(KEY_SOURCE, source.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
const val KEY_DATA = "data"
|
||||||
|
const val KEY_ENTRIES = "entries"
|
||||||
|
const val KEY_ERROR = "error"
|
||||||
|
const val KEY_EXCLUDE = "exclude"
|
||||||
|
const val KEY_FILE = "file"
|
||||||
|
const val KEY_FILTER = "filter"
|
||||||
|
const val KEY_ID = "id"
|
||||||
|
const val KEY_INDEX = "index"
|
||||||
|
const val KEY_LIST_SECTION = "list_section"
|
||||||
|
const val KEY_MANGA = "manga"
|
||||||
|
const val KEY_MANGA_LIST = "manga_list"
|
||||||
|
const val KEY_PAGES = "pages"
|
||||||
|
const val KEY_QUERY = "query"
|
||||||
|
const val KEY_READER_MODE = "reader_mode"
|
||||||
|
const val KEY_SOURCE = "source"
|
||||||
|
const val KEY_TAB = "tab"
|
||||||
|
const val KEY_TITLE = "title"
|
||||||
|
const val KEY_URL = "url"
|
||||||
|
const val KEY_USER_AGENT = "user_agent"
|
||||||
|
|
||||||
|
const val ACTION_HISTORY = "${BuildConfig.APPLICATION_ID}.action.MANAGE_HISTORY"
|
||||||
|
const val ACTION_MANAGE_DOWNLOADS = "${BuildConfig.APPLICATION_ID}.action.MANAGE_DOWNLOADS"
|
||||||
|
const val ACTION_MANAGE_SOURCES = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCES_LIST"
|
||||||
|
const val ACTION_MANGA_EXPLORE = "${BuildConfig.APPLICATION_ID}.action.EXPLORE_MANGA"
|
||||||
|
const val ACTION_PROXY = "${BuildConfig.APPLICATION_ID}.action.MANAGE_PROXY"
|
||||||
|
const val ACTION_READER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_READER_SETTINGS"
|
||||||
|
const val ACTION_SOURCE = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCE_SETTINGS"
|
||||||
|
const val ACTION_SOURCES = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCES"
|
||||||
|
const val ACTION_SUGGESTIONS = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SUGGESTIONS"
|
||||||
|
const val ACTION_TRACKER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_TRACKER"
|
||||||
|
|
||||||
|
private const val ACCOUNT_KEY = "account"
|
||||||
|
private const val ACTION_ACCOUNT_SYNC_SETTINGS = "android.settings.ACCOUNT_SYNC_SETTINGS"
|
||||||
|
private const val EXTRA_SHOW_FRAGMENT_ARGUMENTS = ":settings:show_fragment_args"
|
||||||
|
|
||||||
|
private fun Class<out Fragment>.fragmentTag() = name // TODO
|
||||||
|
|
||||||
|
private inline fun <reified F : Fragment> fragmentTag() = F::class.java.fragmentTag()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,39 @@
|
|||||||
|
package org.koitharu.kotatsu.core.nav
|
||||||
|
|
||||||
|
import android.app.ActivityOptions
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
|
import androidx.fragment.app.DialogFragment
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
|
||||||
|
|
||||||
|
inline val FragmentActivity.router: AppRouter
|
||||||
|
get() = AppRouter(this)
|
||||||
|
|
||||||
|
inline val Fragment.router: AppRouter
|
||||||
|
get() = AppRouter(this)
|
||||||
|
|
||||||
|
tailrec fun Fragment.dismissParentDialog(): Boolean {
|
||||||
|
return when (val parent = parentFragment) {
|
||||||
|
null -> return false
|
||||||
|
is DialogFragment -> {
|
||||||
|
parent.dismiss()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> parent.dismissParentDialog()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun scaleUpActivityOptionsOf(view: View): Bundle? = if (view.context.isAnimationsEnabled) {
|
||||||
|
ActivityOptions.makeScaleUpAnimation(
|
||||||
|
view,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
view.width,
|
||||||
|
view.height,
|
||||||
|
).toBundle()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
@ -0,0 +1,61 @@
|
|||||||
|
package org.koitharu.kotatsu.core.nav
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
|
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||||
|
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
||||||
|
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||||
|
|
||||||
|
@JvmInline
|
||||||
|
value class ReaderIntent private constructor(
|
||||||
|
val intent: Intent,
|
||||||
|
) {
|
||||||
|
|
||||||
|
class Builder(context: Context) {
|
||||||
|
|
||||||
|
private val intent = Intent(context, ReaderActivity::class.java)
|
||||||
|
.setAction(ACTION_MANGA_READ)
|
||||||
|
|
||||||
|
fun manga(manga: Manga) = apply {
|
||||||
|
intent.putExtra(AppRouter.KEY_MANGA, ParcelableManga(manga))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun mangaId(mangaId: Long) = apply {
|
||||||
|
intent.putExtra(AppRouter.KEY_ID, mangaId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun incognito(incognito: Boolean) = apply {
|
||||||
|
intent.putExtra(EXTRA_INCOGNITO, incognito)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun branch(branch: String?) = apply {
|
||||||
|
intent.putExtra(EXTRA_BRANCH, branch)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun state(state: ReaderState?) = apply {
|
||||||
|
intent.putExtra(EXTRA_STATE, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun bookmark(bookmark: Bookmark) = manga(
|
||||||
|
bookmark.manga,
|
||||||
|
).state(
|
||||||
|
ReaderState(
|
||||||
|
chapterId = bookmark.chapterId,
|
||||||
|
page = bookmark.page,
|
||||||
|
scroll = bookmark.scroll,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun build() = ReaderIntent(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val ACTION_MANGA_READ = "${BuildConfig.APPLICATION_ID}.action.READ_MANGA"
|
||||||
|
const val EXTRA_STATE = "state"
|
||||||
|
const val EXTRA_BRANCH = "branch"
|
||||||
|
const val EXTRA_INCOGNITO = "incognito"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,92 @@
|
|||||||
|
package org.koitharu.kotatsu.core.os
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.storage.StorageManager
|
||||||
|
import android.provider.DocumentsContract
|
||||||
|
import androidx.activity.result.ActivityResultCallback
|
||||||
|
import androidx.activity.result.ActivityResultCaller
|
||||||
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
|
import androidx.activity.result.contract.ActivityResultContract
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.core.app.ActivityOptionsCompat
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
|
|
||||||
|
// https://stackoverflow.com/questions/77555641/saf-no-activity-found-to-handle-intent-android-intent-action-open-document-tr
|
||||||
|
class OpenDocumentTreeHelper(
|
||||||
|
activityResultCaller: ActivityResultCaller,
|
||||||
|
flags: Int,
|
||||||
|
callback: ActivityResultCallback<Uri?>
|
||||||
|
) : ActivityResultLauncher<Uri?>() {
|
||||||
|
|
||||||
|
constructor(activityResultCaller: ActivityResultCaller, callback: ActivityResultCallback<Uri?>) : this(
|
||||||
|
activityResultCaller,
|
||||||
|
0,
|
||||||
|
callback,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val pickFileTreeLauncherQ = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
activityResultCaller.registerForActivityResult(OpenDocumentTreeContractQ(flags), callback)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
private val pickFileTreeLauncherLegacy = activityResultCaller.registerForActivityResult(
|
||||||
|
contract = OpenDocumentTreeContractLegacy(flags),
|
||||||
|
callback = callback,
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun launch(input: Uri?, options: ActivityOptionsCompat?) {
|
||||||
|
if (pickFileTreeLauncherQ == null) {
|
||||||
|
pickFileTreeLauncherLegacy.launch(input, options)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
pickFileTreeLauncherQ.launch(input, options)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTraceDebug()
|
||||||
|
pickFileTreeLauncherLegacy.launch(input, options)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun unregister() {
|
||||||
|
pickFileTreeLauncherQ?.unregister()
|
||||||
|
pickFileTreeLauncherLegacy.unregister()
|
||||||
|
}
|
||||||
|
|
||||||
|
override val contract: ActivityResultContract<Uri?, *>
|
||||||
|
get() = pickFileTreeLauncherQ?.contract ?: pickFileTreeLauncherLegacy.contract
|
||||||
|
|
||||||
|
private open class OpenDocumentTreeContractLegacy(
|
||||||
|
private val flags: Int,
|
||||||
|
) : ActivityResultContracts.OpenDocumentTree() {
|
||||||
|
|
||||||
|
override fun createIntent(context: Context, input: Uri?): Intent {
|
||||||
|
val intent = super.createIntent(context, input)
|
||||||
|
intent.addFlags(flags)
|
||||||
|
return intent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.Q)
|
||||||
|
private class OpenDocumentTreeContractQ(
|
||||||
|
private val flags: Int,
|
||||||
|
) : OpenDocumentTreeContractLegacy(flags) {
|
||||||
|
|
||||||
|
override fun createIntent(context: Context, input: Uri?): Intent {
|
||||||
|
val intent = (context.getSystemService(Context.STORAGE_SERVICE) as? StorageManager)
|
||||||
|
?.primaryStorageVolume
|
||||||
|
?.createOpenDocumentTreeIntent()
|
||||||
|
if (intent == null) { // fallback
|
||||||
|
return super.createIntent(context, input)
|
||||||
|
}
|
||||||
|
intent.addFlags(flags)
|
||||||
|
if (input != null) {
|
||||||
|
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, input)
|
||||||
|
}
|
||||||
|
return intent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
package org.koitharu.kotatsu.core.os
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import org.jetbrains.annotations.Blocking
|
||||||
|
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
|
||||||
|
import java.io.InputStreamReader
|
||||||
|
|
||||||
|
object RomCompat {
|
||||||
|
|
||||||
|
val isMiui = suspendLazy<Boolean>(Dispatchers.IO) {
|
||||||
|
getProp("ro.miui.ui.version.name").isNotEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Blocking
|
||||||
|
private fun getProp(propName: String) = Runtime.getRuntime().exec("getprop $propName").inputStream.use {
|
||||||
|
it.reader().use(InputStreamReader::readText).trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
package org.koitharu.kotatsu.core.prefs
|
||||||
|
|
||||||
|
import java.util.EnumSet
|
||||||
|
|
||||||
|
enum class ReaderControl {
|
||||||
|
|
||||||
|
PREV_CHAPTER, NEXT_CHAPTER, SLIDER, PAGES_SHEET, SCREEN_ROTATION, SAVE_PAGE;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
val DEFAULT: Set<ReaderControl> = EnumSet.of(
|
||||||
|
PREV_CHAPTER, NEXT_CHAPTER, SLIDER, PAGES_SHEET,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
package org.koitharu.kotatsu.core.ui.image
|
||||||
|
|
||||||
|
import android.graphics.ColorFilter
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.PixelFormat
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
|
||||||
|
@Suppress("OVERRIDE_DEPRECATION")
|
||||||
|
abstract class PaintDrawable : Drawable() {
|
||||||
|
|
||||||
|
protected abstract val paint: Paint
|
||||||
|
|
||||||
|
override fun setAlpha(alpha: Int) {
|
||||||
|
paint.alpha = alpha
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAlpha(): Int {
|
||||||
|
return paint.alpha
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setColorFilter(colorFilter: ColorFilter?) {
|
||||||
|
paint.colorFilter = colorFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getColorFilter(): ColorFilter? {
|
||||||
|
return paint.colorFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setDither(dither: Boolean) {
|
||||||
|
paint.isDither = dither
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setFilterBitmap(filter: Boolean) {
|
||||||
|
paint.isFilterBitmap = filter
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isFilterBitmap(): Boolean {
|
||||||
|
return paint.isFilterBitmap
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getOpacity(): Int {
|
||||||
|
if (paint.colorFilter != null) {
|
||||||
|
return PixelFormat.TRANSLUCENT
|
||||||
|
}
|
||||||
|
return when (paint.alpha) {
|
||||||
|
0 -> PixelFormat.TRANSPARENT
|
||||||
|
255 -> if (isOpaque()) PixelFormat.OPAQUE else PixelFormat.TRANSLUCENT
|
||||||
|
else -> PixelFormat.TRANSLUCENT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected open fun isOpaque() = false
|
||||||
|
}
|
||||||
@ -0,0 +1,83 @@
|
|||||||
|
package org.koitharu.kotatsu.core.ui.image
|
||||||
|
|
||||||
|
import android.content.res.ColorStateList
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.PointF
|
||||||
|
import android.graphics.Rect
|
||||||
|
import android.os.Build
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.core.graphics.PaintCompat
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.hasFocusStateSpecified
|
||||||
|
|
||||||
|
class TextDrawable(
|
||||||
|
val text: String,
|
||||||
|
) : PaintDrawable() {
|
||||||
|
|
||||||
|
override val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG)
|
||||||
|
private val textBounds = Rect()
|
||||||
|
private val textPoint = PointF()
|
||||||
|
|
||||||
|
var textSize: Float
|
||||||
|
get() = paint.textSize
|
||||||
|
set(value) {
|
||||||
|
paint.textSize = value
|
||||||
|
measureTextBounds()
|
||||||
|
}
|
||||||
|
|
||||||
|
var textColor: ColorStateList = ColorStateList.valueOf(Color.BLACK)
|
||||||
|
set(value) {
|
||||||
|
field = value
|
||||||
|
onStateChange(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
onStateChange(state)
|
||||||
|
measureTextBounds()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun draw(canvas: Canvas) {
|
||||||
|
canvas.drawText(text, textPoint.x, textPoint.y, paint)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBoundsChange(bounds: Rect) {
|
||||||
|
textPoint.set(
|
||||||
|
bounds.exactCenterX() - textBounds.exactCenterX(),
|
||||||
|
bounds.exactCenterY() - textBounds.exactCenterY(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getIntrinsicWidth(): Int = textBounds.width()
|
||||||
|
|
||||||
|
override fun getIntrinsicHeight(): Int = textBounds.height()
|
||||||
|
|
||||||
|
override fun isStateful(): Boolean = textColor.isStateful
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.S)
|
||||||
|
override fun hasFocusStateSpecified(): Boolean = textColor.hasFocusStateSpecified()
|
||||||
|
|
||||||
|
override fun onStateChange(state: IntArray): Boolean {
|
||||||
|
val prevColor = paint.color
|
||||||
|
paint.color = textColor.getColorForState(state, textColor.defaultColor)
|
||||||
|
return paint.color != prevColor
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun measureTextBounds() {
|
||||||
|
paint.getTextBounds(text, 0, text.length, textBounds)
|
||||||
|
onBoundsChange(bounds)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
fun compound(textView: TextView, text: String): TextDrawable? {
|
||||||
|
val drawable = TextDrawable(text)
|
||||||
|
drawable.textSize = textView.textSize
|
||||||
|
drawable.textColor = textView.textColors
|
||||||
|
return drawable.takeIf {
|
||||||
|
PaintCompat.hasGlyph(drawable.paint, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
package org.koitharu.kotatsu.core.ui.image
|
||||||
|
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.annotation.GravityInt
|
||||||
|
import coil3.target.GenericViewTarget
|
||||||
|
|
||||||
|
class TextViewTarget(
|
||||||
|
override val view: TextView,
|
||||||
|
@GravityInt compoundDrawable: Int,
|
||||||
|
) : GenericViewTarget<TextView>() {
|
||||||
|
|
||||||
|
private val drawableIndex: Int = when (compoundDrawable) {
|
||||||
|
Gravity.START -> 0
|
||||||
|
Gravity.TOP -> 2
|
||||||
|
Gravity.END -> 3
|
||||||
|
Gravity.BOTTOM -> 4
|
||||||
|
else -> -1
|
||||||
|
}
|
||||||
|
|
||||||
|
override var drawable: Drawable?
|
||||||
|
get() = if (drawableIndex != -1) {
|
||||||
|
view.compoundDrawablesRelative[drawableIndex]
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
set(value) {
|
||||||
|
if (drawableIndex == -1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val drawables = view.compoundDrawablesRelative
|
||||||
|
drawables[drawableIndex] = value
|
||||||
|
view.setCompoundDrawablesRelativeWithIntrinsicBounds(
|
||||||
|
drawables[0],
|
||||||
|
drawables[1],
|
||||||
|
drawables[2],
|
||||||
|
drawables[3],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,100 @@
|
|||||||
|
package org.koitharu.kotatsu.core.ui.widgets
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Parcel
|
||||||
|
import android.os.Parcelable
|
||||||
|
import android.os.Parcelable.Creator
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import androidx.core.content.withStyledAttributes
|
||||||
|
import androidx.customview.view.AbsSavedState
|
||||||
|
import com.google.android.material.shape.MaterialShapeDrawable
|
||||||
|
import com.google.android.material.shape.ShapeAppearanceModel
|
||||||
|
import com.google.android.material.textview.MaterialTextView
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
|
||||||
|
class BadgeView @JvmOverloads constructor(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet? = null
|
||||||
|
) : MaterialTextView(context, attrs, R.attr.badgeViewStyle) {
|
||||||
|
|
||||||
|
private var maxCharacterCount = Int.MAX_VALUE
|
||||||
|
|
||||||
|
var number: Int = 0
|
||||||
|
set(value) {
|
||||||
|
field = value
|
||||||
|
updateText()
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
context.withStyledAttributes(attrs, R.styleable.BadgeView, R.attr.badgeViewStyle) {
|
||||||
|
maxCharacterCount = getInt(R.styleable.BadgeView_maxCharacterCount, maxCharacterCount)
|
||||||
|
number = getInt(R.styleable.BadgeView_number, number)
|
||||||
|
val shape = ShapeAppearanceModel.builder(
|
||||||
|
context,
|
||||||
|
getResourceId(R.styleable.BadgeView_shapeAppearance, 0),
|
||||||
|
0,
|
||||||
|
).build()
|
||||||
|
background = MaterialShapeDrawable(shape).also { bg ->
|
||||||
|
bg.fillColor = getColorStateList(R.styleable.BadgeView_backgroundColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSaveInstanceState(): Parcelable? {
|
||||||
|
val superState = super.onSaveInstanceState() ?: return null
|
||||||
|
return SavedState(superState, number)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRestoreInstanceState(state: Parcelable?) {
|
||||||
|
if (state is SavedState) {
|
||||||
|
super.onRestoreInstanceState(state.superState)
|
||||||
|
number = state.number
|
||||||
|
} else {
|
||||||
|
super.onRestoreInstanceState(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateText() {
|
||||||
|
if (number <= 0) {
|
||||||
|
text = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val numberString = number.toString()
|
||||||
|
text = if (numberString.length > maxCharacterCount) {
|
||||||
|
buildString(maxCharacterCount) {
|
||||||
|
repeat(maxCharacterCount - 1) { append('9') }
|
||||||
|
append('+')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
numberString
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class SavedState : AbsSavedState {
|
||||||
|
|
||||||
|
val number: Int
|
||||||
|
|
||||||
|
constructor(superState: Parcelable, number: Int) : super(superState) {
|
||||||
|
this.number = number
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: Parcel, classLoader: ClassLoader?) : super(source, classLoader) {
|
||||||
|
number = source.readInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun writeToParcel(out: Parcel, flags: Int) {
|
||||||
|
super.writeToParcel(out, flags)
|
||||||
|
out.writeInt(number)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@Suppress("unused")
|
||||||
|
@JvmField
|
||||||
|
val CREATOR: Creator<SavedState> = object : Creator<SavedState> {
|
||||||
|
override fun createFromParcel(`in`: Parcel) = SavedState(`in`, SavedState::class.java.classLoader)
|
||||||
|
|
||||||
|
override fun newArray(size: Int): Array<SavedState?> = arrayOfNulls(size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,90 @@
|
|||||||
|
package org.koitharu.kotatsu.core.ui.widgets
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import androidx.core.content.withStyledAttributes
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
|
||||||
|
class IconsView @JvmOverloads constructor(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet? = null,
|
||||||
|
) : LinearLayout(context, attrs) {
|
||||||
|
|
||||||
|
private var iconSize = LinearLayout.LayoutParams.WRAP_CONTENT
|
||||||
|
private var iconSpacing = 0
|
||||||
|
|
||||||
|
val iconsCount: Int
|
||||||
|
get() {
|
||||||
|
var count = 0
|
||||||
|
repeat(childCount) { i ->
|
||||||
|
if (getChildAt(i).isVisible) {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
context.withStyledAttributes(attrs, R.styleable.IconsView) {
|
||||||
|
iconSize = getDimensionPixelSize(R.styleable.IconsView_iconSize, iconSize)
|
||||||
|
iconSpacing = getDimensionPixelOffset(R.styleable.IconsView_iconSpacing, iconSpacing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setIcons(icons: Iterable<Drawable>) {
|
||||||
|
var index = 0
|
||||||
|
for (icon in icons) {
|
||||||
|
val imageView = (getChildAt(index) as ImageView?) ?: addImageView()
|
||||||
|
imageView.setImageDrawable(icon)
|
||||||
|
imageView.isVisible = true
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
for (i in index until childCount) {
|
||||||
|
val imageView = getChildAt(i) as? ImageView ?: continue
|
||||||
|
imageView.setImageDrawable(null)
|
||||||
|
imageView.isVisible = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearIcons() {
|
||||||
|
repeat(childCount) { i ->
|
||||||
|
getChildAt(i).isVisible = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addIcon(drawable: Drawable) {
|
||||||
|
val imageView = getNextImageView()
|
||||||
|
imageView.setImageDrawable(drawable)
|
||||||
|
imageView.isVisible = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addIcon(@DrawableRes resId: Int) {
|
||||||
|
val imageView = getNextImageView()
|
||||||
|
imageView.setImageResource(resId)
|
||||||
|
imageView.isVisible = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getNextImageView(): ImageView {
|
||||||
|
repeat(childCount) { i ->
|
||||||
|
val child = getChildAt(i)
|
||||||
|
if (child is ImageView && !child.isVisible) {
|
||||||
|
return child
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return addImageView()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addImageView() = ImageView(context).also {
|
||||||
|
it.scaleType = ImageView.ScaleType.FIT_CENTER
|
||||||
|
val lp = LayoutParams(iconSize, iconSize)
|
||||||
|
if (childCount != 0) {
|
||||||
|
lp.marginStart = iconSpacing
|
||||||
|
}
|
||||||
|
addView(it, lp)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
package org.koitharu.kotatsu.core.util
|
||||||
|
|
||||||
|
import androidx.core.os.LocaleListCompat
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.indexOfContains
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.iterator
|
||||||
|
|
||||||
|
class LocaleStringComparator : Comparator<String?> {
|
||||||
|
|
||||||
|
private val deviceLocales: List<String?>
|
||||||
|
|
||||||
|
init {
|
||||||
|
val localeList = LocaleListCompat.getAdjustedDefault()
|
||||||
|
deviceLocales = buildList(localeList.size() + 1) {
|
||||||
|
add(null)
|
||||||
|
val set = HashSet<String?>(localeList.size() + 1)
|
||||||
|
set.add(null)
|
||||||
|
for (locale in localeList) {
|
||||||
|
val lang = locale.getDisplayLanguage(locale)
|
||||||
|
if (set.add(lang)) {
|
||||||
|
add(lang)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun compare(a: String?, b: String?): Int {
|
||||||
|
val indexA = deviceLocales.indexOfContains(a, true)
|
||||||
|
val indexB = deviceLocales.indexOfContains(b, true)
|
||||||
|
return when {
|
||||||
|
indexA < 0 && indexB < 0 -> compareValues(a, b)
|
||||||
|
indexA < 0 -> 1
|
||||||
|
indexB < 0 -> -1
|
||||||
|
else -> compareValues(indexA, indexB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
package org.koitharu.kotatsu.core.util
|
||||||
|
|
||||||
|
import android.graphics.Paint
|
||||||
|
import androidx.core.graphics.PaintCompat
|
||||||
|
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
object LocaleUtils {
|
||||||
|
|
||||||
|
private val paint = Paint()
|
||||||
|
|
||||||
|
fun getEmojiFlag(locale: Locale): String? {
|
||||||
|
val code = when (val c = locale.country.ifNullOrEmpty { locale.toLanguageTag() }.uppercase(Locale.ENGLISH)) {
|
||||||
|
"EN" -> "GB"
|
||||||
|
"JA" -> "JP"
|
||||||
|
"VI" -> "VN"
|
||||||
|
"ZH" -> "CN"
|
||||||
|
else -> c
|
||||||
|
}
|
||||||
|
val emoji = countryCodeToEmojiFlag(code)
|
||||||
|
return if (PaintCompat.hasGlyph(paint, emoji)) {
|
||||||
|
emoji
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun countryCodeToEmojiFlag(countryCode: String): String {
|
||||||
|
return countryCode.map { char ->
|
||||||
|
Character.codePointAt("$char", 0) - 0x41 + 0x1F1E6
|
||||||
|
}.map { codePoint ->
|
||||||
|
Character.toChars(codePoint)
|
||||||
|
}.joinToString(separator = "") { charArray ->
|
||||||
|
String(charArray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
package org.koitharu.kotatsu.core.util
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import android.webkit.MimeTypeMap
|
||||||
|
import org.jetbrains.annotations.Blocking
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.MimeType
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.toMimeTypeOrNull
|
||||||
|
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
|
||||||
|
import org.koitharu.kotatsu.parsers.util.removeSuffix
|
||||||
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
|
import java.io.File
|
||||||
|
import java.nio.file.Files
|
||||||
|
import coil3.util.MimeTypeMap as CoilMimeTypeMap
|
||||||
|
|
||||||
|
object MimeTypes {
|
||||||
|
|
||||||
|
fun getMimeTypeFromExtension(fileName: String): MimeType? {
|
||||||
|
return CoilMimeTypeMap.getMimeTypeFromExtension(getNormalizedExtension(fileName) ?: return null)
|
||||||
|
?.toMimeTypeOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getMimeTypeFromUrl(url: String): MimeType? {
|
||||||
|
return CoilMimeTypeMap.getMimeTypeFromUrl(url)?.toMimeTypeOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getExtension(mimeType: MimeType?): String? {
|
||||||
|
return MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType?.toString() ?: return null)?.nullIfEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Blocking
|
||||||
|
fun probeMimeType(file: File): MimeType? {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
runCatchingCancellable {
|
||||||
|
Files.probeContentType(file.toPath())?.toMimeTypeOrNull()
|
||||||
|
}.getOrNull()?.let { return it }
|
||||||
|
}
|
||||||
|
return getMimeTypeFromExtension(file.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getNormalizedExtension(name: String): String? = name
|
||||||
|
.lowercase()
|
||||||
|
.removeSuffix('~')
|
||||||
|
.removeSuffix(".tmp")
|
||||||
|
.substringAfterLast('.', "")
|
||||||
|
.takeIf { it.length in 2..5 }
|
||||||
|
}
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
package org.koitharu.kotatsu.core.util.ext
|
||||||
|
|
||||||
|
import okhttp3.MediaType
|
||||||
|
|
||||||
|
private const val TYPE_IMAGE = "image"
|
||||||
|
private val REGEX_MIME = Regex("^\\w+/([-+.\\w]+|\\*)$", RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
|
@JvmInline
|
||||||
|
value class MimeType(private val value: String) {
|
||||||
|
|
||||||
|
val type: String?
|
||||||
|
get() = value.substringBefore('/', "").takeIfSpecified()
|
||||||
|
|
||||||
|
val subtype: String?
|
||||||
|
get() = value.substringAfterLast('/', "").takeIfSpecified()
|
||||||
|
|
||||||
|
private fun String.takeIfSpecified(): String? = takeUnless {
|
||||||
|
it.isEmpty() || it == "*"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString(): String = value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun MediaType.toMimeType(): MimeType = MimeType("$type/$subtype")
|
||||||
|
|
||||||
|
fun String.toMimeTypeOrNull(): MimeType? = if (REGEX_MIME.matches(this)) {
|
||||||
|
MimeType(lowercase())
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
val MimeType.isImage: Boolean
|
||||||
|
get() = type == TYPE_IMAGE
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
package org.koitharu.kotatsu.core.util.progress
|
||||||
|
|
||||||
|
data class Progress(
|
||||||
|
val progress: Int,
|
||||||
|
val total: Int,
|
||||||
|
) : Comparable<Progress> {
|
||||||
|
|
||||||
|
val percent: Float
|
||||||
|
get() = if (total == 0) 0f else progress / total.toFloat()
|
||||||
|
|
||||||
|
val isEmpty: Boolean
|
||||||
|
get() = progress == 0
|
||||||
|
|
||||||
|
val isFull: Boolean
|
||||||
|
get() = progress == total
|
||||||
|
|
||||||
|
override fun compareTo(other: Progress): Int = if (total == other.total) {
|
||||||
|
progress.compareTo(other.progress)
|
||||||
|
} else {
|
||||||
|
percent.compareTo(other.percent)
|
||||||
|
}
|
||||||
|
|
||||||
|
operator fun inc() = if (isFull) {
|
||||||
|
this
|
||||||
|
} else {
|
||||||
|
copy(
|
||||||
|
progress = progress + 1,
|
||||||
|
total = total,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
operator fun dec() = if (isEmpty) {
|
||||||
|
this
|
||||||
|
} else {
|
||||||
|
copy(
|
||||||
|
progress = progress - 1,
|
||||||
|
total = total,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
operator fun plus(child: Progress) = Progress(
|
||||||
|
progress = progress * child.total + child.progress,
|
||||||
|
total = total * child.total,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun percentSting() = (percent * 100f).toInt().toString()
|
||||||
|
}
|
||||||
@ -1,8 +1,11 @@
|
|||||||
package org.koitharu.kotatsu.details.domain
|
package org.koitharu.kotatsu.details.domain
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.core.util.LocaleStringComparator
|
||||||
import org.koitharu.kotatsu.details.ui.model.MangaBranch
|
import org.koitharu.kotatsu.details.ui.model.MangaBranch
|
||||||
|
|
||||||
class BranchComparator : Comparator<MangaBranch> {
|
class BranchComparator : Comparator<MangaBranch> {
|
||||||
|
|
||||||
override fun compare(o1: MangaBranch, o2: MangaBranch): Int = compareValues(o1.name, o2.name)
|
private val delegate = LocaleStringComparator()
|
||||||
|
|
||||||
|
override fun compare(o1: MangaBranch, o2: MangaBranch): Int = delegate.compare(o1.name, o2.name)
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue