Merge branch 'devel'
commit
7a663fa9c1
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,21 @@
|
|||||||
|
package org.koitharu.kotatsu.core.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.lifecycle.LifecycleService
|
||||||
|
import leakcanary.AppWatcher
|
||||||
|
|
||||||
|
abstract class BaseService : LifecycleService() {
|
||||||
|
|
||||||
|
override fun attachBaseContext(newBase: Context) {
|
||||||
|
super.attachBaseContext(ContextCompat.getContextForLanguage(newBase))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
AppWatcher.objectWatcher.watch(
|
||||||
|
watchedObject = this,
|
||||||
|
description = "${javaClass.simpleName} service received Service#onDestroy() callback",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,54 +1,5 @@
|
|||||||
package org.koitharu.kotatsu.bookmarks.ui
|
package org.koitharu.kotatsu.bookmarks.ui
|
||||||
|
|
||||||
import android.content.Context
|
import org.koitharu.kotatsu.core.ui.FragmentContainerActivity
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Bundle
|
|
||||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
|
||||||
import androidx.core.graphics.Insets
|
|
||||||
import androidx.core.view.updatePadding
|
|
||||||
import androidx.fragment.app.commit
|
|
||||||
import com.google.android.material.appbar.AppBarLayout
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
|
||||||
import org.koitharu.kotatsu.databinding.ActivityContainerBinding
|
|
||||||
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
|
||||||
import org.koitharu.kotatsu.main.ui.owners.SnackbarOwner
|
|
||||||
|
|
||||||
@AndroidEntryPoint
|
class AllBookmarksActivity : FragmentContainerActivity(AllBookmarksFragment::class.java)
|
||||||
class AllBookmarksActivity :
|
|
||||||
BaseActivity<ActivityContainerBinding>(),
|
|
||||||
AppBarOwner,
|
|
||||||
SnackbarOwner {
|
|
||||||
|
|
||||||
override val appBar: AppBarLayout
|
|
||||||
get() = viewBinding.appbar
|
|
||||||
|
|
||||||
override val snackbarHost: CoordinatorLayout
|
|
||||||
get() = viewBinding.root
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
setContentView(ActivityContainerBinding.inflate(layoutInflater))
|
|
||||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
|
||||||
val fm = supportFragmentManager
|
|
||||||
if (fm.findFragmentById(R.id.container) == null) {
|
|
||||||
fm.commit {
|
|
||||||
setReorderingAllowed(true)
|
|
||||||
replace(R.id.container, AllBookmarksFragment::class.java, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onWindowInsetsChanged(insets: Insets) {
|
|
||||||
viewBinding.root.updatePadding(
|
|
||||||
left = insets.left,
|
|
||||||
right = insets.right,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
fun newIntent(context: Context) = Intent(context, AllBookmarksActivity::class.java)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,38 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.bookmarks.ui.adapter
|
|
||||||
|
|
||||||
import androidx.lifecycle.LifecycleOwner
|
|
||||||
import coil3.ImageLoader
|
|
||||||
import coil3.request.allowRgb565
|
|
||||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
|
||||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
|
||||||
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
|
|
||||||
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.bookmarkExtra
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.decodeRegion
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
|
||||||
import org.koitharu.kotatsu.databinding.ItemBookmarkBinding
|
|
||||||
|
|
||||||
// TODO check usages
|
|
||||||
fun bookmarkListAD(
|
|
||||||
coil: ImageLoader,
|
|
||||||
lifecycleOwner: LifecycleOwner,
|
|
||||||
clickListener: OnListItemClickListener<Bookmark>,
|
|
||||||
) = adapterDelegateViewBinding<Bookmark, Bookmark, ItemBookmarkBinding>(
|
|
||||||
{ inflater, parent -> ItemBookmarkBinding.inflate(inflater, parent, false) },
|
|
||||||
) {
|
|
||||||
AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView)
|
|
||||||
|
|
||||||
bind {
|
|
||||||
binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageLoadData)?.run {
|
|
||||||
size(CoverSizeResolver(binding.imageViewThumb))
|
|
||||||
defaultPlaceholders(context)
|
|
||||||
allowRgb565(true)
|
|
||||||
bookmarkExtra(item)
|
|
||||||
decodeRegion(item.scroll)
|
|
||||||
enqueueWith(coil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,82 @@
|
|||||||
|
package org.koitharu.kotatsu.browser
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.core.view.updatePadding
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import org.koitharu.kotatsu.core.network.proxy.ProxyProvider
|
||||||
|
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.consumeAll
|
||||||
|
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
abstract class BaseBrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var proxyProvider: ProxyProvider
|
||||||
|
|
||||||
|
private lateinit var onBackPressedCallback: WebViewBackPressedCallback
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
|
||||||
|
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView)
|
||||||
|
onBackPressedDispatcher.addCallback(onBackPressedCallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onApplyWindowInsets(
|
||||||
|
v: View,
|
||||||
|
insets: WindowInsetsCompat
|
||||||
|
): WindowInsetsCompat {
|
||||||
|
val type = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime()
|
||||||
|
val barsInsets = insets.getInsets(type)
|
||||||
|
viewBinding.webView.updatePadding(
|
||||||
|
left = barsInsets.left,
|
||||||
|
right = barsInsets.right,
|
||||||
|
bottom = barsInsets.bottom,
|
||||||
|
)
|
||||||
|
viewBinding.appbar.updatePadding(
|
||||||
|
left = barsInsets.left,
|
||||||
|
right = barsInsets.right,
|
||||||
|
top = barsInsets.top,
|
||||||
|
)
|
||||||
|
return insets.consumeAll(type)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
viewBinding.webView.onPause()
|
||||||
|
super.onPause()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
viewBinding.webView.onResume()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
if (hasViewBinding()) {
|
||||||
|
viewBinding.webView.stopLoading()
|
||||||
|
viewBinding.webView.destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLoadingStateChanged(isLoading: Boolean) {
|
||||||
|
viewBinding.progressBar.isVisible = isLoading
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) {
|
||||||
|
this.title = title
|
||||||
|
supportActionBar?.subtitle = subtitle
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onHistoryChanged() {
|
||||||
|
onBackPressedCallback.onHistoryChanged()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
package org.koitharu.kotatsu.core
|
||||||
|
|
||||||
|
import javax.inject.Qualifier
|
||||||
|
|
||||||
|
@Qualifier
|
||||||
|
@Target(
|
||||||
|
AnnotationTarget.FUNCTION,
|
||||||
|
AnnotationTarget.PROPERTY_GETTER,
|
||||||
|
AnnotationTarget.PROPERTY_SETTER,
|
||||||
|
AnnotationTarget.VALUE_PARAMETER,
|
||||||
|
AnnotationTarget.FIELD,
|
||||||
|
)
|
||||||
|
annotation class LocalizedAppContext
|
||||||
@ -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 title: 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,12 @@
|
|||||||
|
package org.koitharu.kotatsu.core.db.migrations
|
||||||
|
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
|
||||||
|
class Migration24To25 : Migration(24, 25) {
|
||||||
|
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("ALTER TABLE manga ADD COLUMN content_rating TEXT DEFAULT NULL")
|
||||||
|
db.execSQL("UPDATE manga SET content_rating = 'ADULT' WHERE nsfw = 1")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,803 @@
|
|||||||
|
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.DialogInterface
|
||||||
|
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.annotation.UiContext
|
||||||
|
import androidx.core.app.ShareCompat
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
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 dagger.hilt.android.EntryPointAccessors
|
||||||
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
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.appUrl
|
||||||
|
import org.koitharu.kotatsu.core.model.getTitle
|
||||||
|
import org.koitharu.kotatsu.core.model.isBroken
|
||||||
|
import org.koitharu.kotatsu.core.model.isLocal
|
||||||
|
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.AppSettings
|
||||||
|
import org.koitharu.kotatsu.core.prefs.ReaderMode
|
||||||
|
import org.koitharu.kotatsu.core.prefs.TriStateOption
|
||||||
|
import org.koitharu.kotatsu.core.ui.dialog.BigButtonsAlertDialog
|
||||||
|
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
|
||||||
|
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.connectivityManager
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.findActivity
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.getThemeDrawable
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
|
||||||
|
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.model.SortOrder
|
||||||
|
import org.koitharu.kotatsu.parsers.util.ellipsize
|
||||||
|
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.domain.SearchKind
|
||||||
|
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
|
||||||
|
import java.io.File
|
||||||
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
|
class AppRouter private constructor(
|
||||||
|
private val activity: FragmentActivity?,
|
||||||
|
private val fragment: Fragment?,
|
||||||
|
) {
|
||||||
|
|
||||||
|
constructor(activity: FragmentActivity) : this(activity, null)
|
||||||
|
|
||||||
|
constructor(fragment: Fragment) : this(null, fragment)
|
||||||
|
|
||||||
|
private val settings: AppSettings by lazy {
|
||||||
|
EntryPointAccessors.fromApplication<AppRouterEntryPoint>(checkNotNull(contextOrNull())).settings
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Activities **/
|
||||||
|
|
||||||
|
fun openList(source: MangaSource, filter: MangaListFilter?, sortOrder: SortOrder?) {
|
||||||
|
startActivity(listIntent(contextOrNull() ?: return, source, filter, sortOrder))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openList(tag: MangaTag) = openList(tag.source, MangaListFilter(tags = setOf(tag)), null)
|
||||||
|
|
||||||
|
fun openSearch(query: String, kind: SearchKind = SearchKind.SIMPLE) {
|
||||||
|
startActivity(
|
||||||
|
Intent(contextOrNull() ?: return, SearchActivity::class.java)
|
||||||
|
.putExtra(KEY_QUERY, query)
|
||||||
|
.putExtra(KEY_KIND, kind),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openSearch(source: MangaSource, query: String) = openList(source, MangaListFilter(query = query), null)
|
||||||
|
|
||||||
|
fun openDetails(manga: Manga) {
|
||||||
|
startActivity(detailsIntent(contextOrNull() ?: return, manga))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openDetails(mangaId: Long) {
|
||||||
|
startActivity(detailsIntent(contextOrNull() ?: return, mangaId))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openDetails(link: Uri) {
|
||||||
|
startActivity(
|
||||||
|
Intent(contextOrNull() ?: return, DetailsActivity::class.java)
|
||||||
|
.setData(link),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 showTagDialog(tag: MangaTag) {
|
||||||
|
buildAlertDialog(contextOrNull() ?: return) {
|
||||||
|
setIcon(R.drawable.ic_tag)
|
||||||
|
setTitle(tag.title)
|
||||||
|
setItems(
|
||||||
|
arrayOf(
|
||||||
|
context.getString(R.string.search_on_s, tag.source.getTitle(context)),
|
||||||
|
context.getString(R.string.search_everywhere),
|
||||||
|
),
|
||||||
|
) { _, which ->
|
||||||
|
when (which) {
|
||||||
|
0 -> openList(tag)
|
||||||
|
1 -> openSearch(tag.title, SearchKind.TAG)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setNegativeButton(R.string.close, null)
|
||||||
|
setCancelable(true)
|
||||||
|
}.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showAuthorDialog(author: String, source: MangaSource) {
|
||||||
|
buildAlertDialog(contextOrNull() ?: return) {
|
||||||
|
setIcon(R.drawable.ic_user)
|
||||||
|
setTitle(author)
|
||||||
|
setItems(
|
||||||
|
arrayOf(
|
||||||
|
context.getString(R.string.search_on_s, source.getTitle(context)),
|
||||||
|
context.getString(R.string.search_everywhere),
|
||||||
|
),
|
||||||
|
) { _, which ->
|
||||||
|
when (which) {
|
||||||
|
0 -> openList(source, MangaListFilter(author = author), null)
|
||||||
|
1 -> openSearch(author, SearchKind.AUTHOR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setNegativeButton(R.string.close, null)
|
||||||
|
setCancelable(true)
|
||||||
|
}.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showShareDialog(manga: Manga) {
|
||||||
|
if (manga.isBroken) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (manga.isLocal) {
|
||||||
|
manga.url.toUri().toFileOrNull()?.let {
|
||||||
|
shareFile(it)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
buildAlertDialog(contextOrNull() ?: return) {
|
||||||
|
setIcon(context.getThemeDrawable(materialR.attr.actionModeShareDrawable))
|
||||||
|
setTitle(R.string.share)
|
||||||
|
setItems(
|
||||||
|
arrayOf(
|
||||||
|
context.getString(R.string.link_to_manga_in_app),
|
||||||
|
context.getString(R.string.link_to_manga_on_s, manga.source.getTitle(context)),
|
||||||
|
),
|
||||||
|
) { _, which ->
|
||||||
|
val link = when (which) {
|
||||||
|
0 -> manga.appUrl.toString()
|
||||||
|
1 -> manga.publicUrl
|
||||||
|
else -> return@setItems
|
||||||
|
}
|
||||||
|
shareLink(link, manga.title)
|
||||||
|
}
|
||||||
|
setNegativeButton(android.R.string.cancel, null)
|
||||||
|
setCancelable(true)
|
||||||
|
}.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun askForDownloadOverMeteredNetwork(onConfirmed: (allow: Boolean) -> Unit) {
|
||||||
|
val context = contextOrNull() ?: return
|
||||||
|
when (settings.allowDownloadOnMeteredNetwork) {
|
||||||
|
TriStateOption.ENABLED -> onConfirmed(true)
|
||||||
|
TriStateOption.DISABLED -> onConfirmed(false)
|
||||||
|
TriStateOption.ASK -> {
|
||||||
|
if (!context.connectivityManager.isActiveNetworkMetered) {
|
||||||
|
onConfirmed(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val listener = DialogInterface.OnClickListener { _, which ->
|
||||||
|
when (which) {
|
||||||
|
DialogInterface.BUTTON_POSITIVE -> {
|
||||||
|
settings.allowDownloadOnMeteredNetwork = TriStateOption.ENABLED
|
||||||
|
onConfirmed(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
DialogInterface.BUTTON_NEUTRAL -> {
|
||||||
|
onConfirmed(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
DialogInterface.BUTTON_NEGATIVE -> {
|
||||||
|
settings.allowDownloadOnMeteredNetwork = TriStateOption.DISABLED
|
||||||
|
onConfirmed(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BigButtonsAlertDialog.Builder(context)
|
||||||
|
.setIcon(R.drawable.ic_network_cellular)
|
||||||
|
.setTitle(R.string.download_cellular_confirm)
|
||||||
|
.setPositiveButton(R.string.allow_always, listener)
|
||||||
|
.setNeutralButton(R.string.allow_once, listener)
|
||||||
|
.setNegativeButton(R.string.dont_allow, listener)
|
||||||
|
.create()
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 shareLink(link: String, title: String) {
|
||||||
|
val context = contextOrNull() ?: return
|
||||||
|
ShareCompat.IntentBuilder(context)
|
||||||
|
.setText(link)
|
||||||
|
.setType(TYPE_TEXT)
|
||||||
|
.setChooserTitle(context.getString(R.string.share_s, title.ellipsize(12)))
|
||||||
|
.startChooser()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shareFile(file: File) { // TODO directory sharing support
|
||||||
|
val context = contextOrNull() ?: return
|
||||||
|
val intentBuilder = ShareCompat.IntentBuilder(context)
|
||||||
|
.setType(TYPE_CBZ)
|
||||||
|
val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.files", file)
|
||||||
|
intentBuilder.addStream(uri)
|
||||||
|
intentBuilder.setChooserTitle(context.getString(R.string.share_s, file.name))
|
||||||
|
intentBuilder.startChooser()
|
||||||
|
}
|
||||||
|
|
||||||
|
@UiContext
|
||||||
|
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?, sortOrder: SortOrder?): Intent =
|
||||||
|
Intent(context, MangaListActivity::class.java)
|
||||||
|
.setAction(ACTION_MANGA_EXPLORE)
|
||||||
|
.putExtra(KEY_SOURCE, source.name)
|
||||||
|
.apply {
|
||||||
|
if (!filter.isNullOrEmpty()) {
|
||||||
|
putExtra(KEY_FILTER, ParcelableMangaListFilter(filter))
|
||||||
|
}
|
||||||
|
if (sortOrder != null) {
|
||||||
|
putExtra(KEY_SORT_ORDER, sortOrder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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[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)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isShareSupported(manga: Manga): Boolean = when {
|
||||||
|
manga.isBroken -> false
|
||||||
|
manga.isLocal -> manga.url.toUri().toFileOrNull() != null
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
|
||||||
|
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_KIND = "kind"
|
||||||
|
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_SORT_ORDER = "sort_order"
|
||||||
|
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 const val TYPE_TEXT = "text/plain"
|
||||||
|
private const val TYPE_IMAGE = "image/*"
|
||||||
|
private const val TYPE_CBZ = "application/x-cbz"
|
||||||
|
|
||||||
|
private fun Class<out Fragment>.fragmentTag() = name // TODO
|
||||||
|
|
||||||
|
private inline fun <reified F : Fragment> fragmentTag() = F::class.java.fragmentTag()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
package org.koitharu.kotatsu.core.nav
|
||||||
|
|
||||||
|
import dagger.hilt.EntryPoint
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
|
||||||
|
@EntryPoint
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
interface AppRouterEntryPoint {
|
||||||
|
|
||||||
|
val settings: AppSettings
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,51 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.network
|
|
||||||
|
|
||||||
import okio.IOException
|
|
||||||
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
|
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
|
||||||
import java.net.InetSocketAddress
|
|
||||||
import java.net.Proxy
|
|
||||||
import java.net.ProxySelector
|
|
||||||
import java.net.SocketAddress
|
|
||||||
import java.net.URI
|
|
||||||
|
|
||||||
class AppProxySelector(
|
|
||||||
private val settings: AppSettings,
|
|
||||||
) : ProxySelector() {
|
|
||||||
|
|
||||||
init {
|
|
||||||
setDefault(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var cachedProxy: Proxy? = null
|
|
||||||
|
|
||||||
override fun select(uri: URI?): List<Proxy> {
|
|
||||||
return listOf(getProxy())
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun connectFailed(uri: URI?, sa: SocketAddress?, ioe: IOException?) {
|
|
||||||
ioe?.printStackTraceDebug()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getProxy(): Proxy {
|
|
||||||
val type = settings.proxyType
|
|
||||||
val address = settings.proxyAddress
|
|
||||||
val port = settings.proxyPort
|
|
||||||
if (type == Proxy.Type.DIRECT) {
|
|
||||||
return Proxy.NO_PROXY
|
|
||||||
}
|
|
||||||
if (address.isNullOrEmpty() || port < 0 || port > 0xFFFF) {
|
|
||||||
throw ProxyConfigException()
|
|
||||||
}
|
|
||||||
cachedProxy?.let {
|
|
||||||
val addr = it.address() as? InetSocketAddress
|
|
||||||
if (addr != null && it.type() == type && addr.port == port && addr.hostString == address) {
|
|
||||||
return it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val proxy = Proxy(type, InetSocketAddress(address, port))
|
|
||||||
cachedProxy = proxy
|
|
||||||
return proxy
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.network
|
|
||||||
|
|
||||||
import okhttp3.Authenticator
|
|
||||||
import okhttp3.Credentials
|
|
||||||
import okhttp3.Request
|
|
||||||
import okhttp3.Response
|
|
||||||
import okhttp3.Route
|
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
|
||||||
import java.net.PasswordAuthentication
|
|
||||||
import java.net.Proxy
|
|
||||||
|
|
||||||
class ProxyAuthenticator(
|
|
||||||
private val settings: AppSettings,
|
|
||||||
) : Authenticator, java.net.Authenticator() {
|
|
||||||
|
|
||||||
init {
|
|
||||||
setDefault(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun authenticate(route: Route?, response: Response): Request? {
|
|
||||||
if (!isProxyEnabled()) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
if (response.request.header(CommonHeaders.PROXY_AUTHORIZATION) != null) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
val login = settings.proxyLogin ?: return null
|
|
||||||
val password = settings.proxyPassword ?: return null
|
|
||||||
val credential = Credentials.basic(login, password)
|
|
||||||
return response.request.newBuilder()
|
|
||||||
.header(CommonHeaders.PROXY_AUTHORIZATION, credential)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getPasswordAuthentication(): PasswordAuthentication? {
|
|
||||||
if (!isProxyEnabled()) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
val login = settings.proxyLogin ?: return null
|
|
||||||
val password = settings.proxyPassword ?: return null
|
|
||||||
return PasswordAuthentication(login, password.toCharArray())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isProxyEnabled() = settings.proxyType != Proxy.Type.DIRECT
|
|
||||||
}
|
|
||||||
@ -0,0 +1,150 @@
|
|||||||
|
package org.koitharu.kotatsu.core.network.proxy
|
||||||
|
|
||||||
|
import androidx.webkit.ProxyConfig
|
||||||
|
import androidx.webkit.ProxyController
|
||||||
|
import androidx.webkit.WebViewFeature
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.asExecutor
|
||||||
|
import okhttp3.Authenticator
|
||||||
|
import okhttp3.Credentials
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.Route
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
|
||||||
|
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.PasswordAuthentication
|
||||||
|
import java.net.Proxy
|
||||||
|
import java.net.ProxySelector
|
||||||
|
import java.net.SocketAddress
|
||||||
|
import java.net.URI
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlin.coroutines.suspendCoroutine
|
||||||
|
import java.net.Authenticator as JavaAuthenticator
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class ProxyProvider @Inject constructor(
|
||||||
|
private val settings: AppSettings,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private var cachedProxy: Proxy? = null
|
||||||
|
|
||||||
|
val selector = object : ProxySelector() {
|
||||||
|
override fun select(uri: URI?): List<Proxy> {
|
||||||
|
return listOf(getProxy())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun connectFailed(uri: URI?, sa: SocketAddress?, ioe: okio.IOException?) {
|
||||||
|
ioe?.printStackTraceDebug()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val authenticator = ProxyAuthenticator()
|
||||||
|
|
||||||
|
init {
|
||||||
|
ProxySelector.setDefault(selector)
|
||||||
|
JavaAuthenticator.setDefault(authenticator)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun applyWebViewConfig() {
|
||||||
|
val isProxyEnabled = isProxyEnabled()
|
||||||
|
if (!WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE)) {
|
||||||
|
if (isProxyEnabled) {
|
||||||
|
throw IllegalArgumentException("Proxy for WebView is not supported") // TODO localize
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val controller = ProxyController.getInstance()
|
||||||
|
if (settings.proxyType == Proxy.Type.DIRECT) {
|
||||||
|
suspendCoroutine { cont ->
|
||||||
|
controller.clearProxyOverride(
|
||||||
|
(cont.context[CoroutineDispatcher] ?: Dispatchers.Main).asExecutor(),
|
||||||
|
) {
|
||||||
|
cont.resume(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val url = buildString {
|
||||||
|
when (settings.proxyType) {
|
||||||
|
Proxy.Type.DIRECT -> Unit
|
||||||
|
Proxy.Type.HTTP -> append("http")
|
||||||
|
Proxy.Type.SOCKS -> append("socks")
|
||||||
|
}
|
||||||
|
append("://")
|
||||||
|
append(settings.proxyAddress)
|
||||||
|
append(':')
|
||||||
|
append(settings.proxyPort)
|
||||||
|
}
|
||||||
|
if (settings.proxyType == Proxy.Type.SOCKS) {
|
||||||
|
System.setProperty("java.net.socks.username", settings.proxyLogin);
|
||||||
|
System.setProperty("java.net.socks.password", settings.proxyPassword);
|
||||||
|
}
|
||||||
|
val proxyConfig = ProxyConfig.Builder()
|
||||||
|
.addProxyRule(url)
|
||||||
|
.build()
|
||||||
|
suspendCoroutine { cont ->
|
||||||
|
controller.setProxyOverride(
|
||||||
|
proxyConfig,
|
||||||
|
(cont.context[CoroutineDispatcher] ?: Dispatchers.Main).asExecutor(),
|
||||||
|
) {
|
||||||
|
cont.resume(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isProxyEnabled() = settings.proxyType != Proxy.Type.DIRECT
|
||||||
|
|
||||||
|
private fun getProxy(): Proxy {
|
||||||
|
val type = settings.proxyType
|
||||||
|
val address = settings.proxyAddress
|
||||||
|
val port = settings.proxyPort
|
||||||
|
if (type == Proxy.Type.DIRECT) {
|
||||||
|
return Proxy.NO_PROXY
|
||||||
|
}
|
||||||
|
if (address.isNullOrEmpty() || port < 0 || port > 0xFFFF) {
|
||||||
|
throw ProxyConfigException()
|
||||||
|
}
|
||||||
|
cachedProxy?.let {
|
||||||
|
val addr = it.address() as? InetSocketAddress
|
||||||
|
if (addr != null && it.type() == type && addr.port == port && addr.hostString == address) {
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val proxy = Proxy(type, InetSocketAddress(address, port))
|
||||||
|
cachedProxy = proxy
|
||||||
|
return proxy
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class ProxyAuthenticator : Authenticator, JavaAuthenticator() {
|
||||||
|
|
||||||
|
override fun authenticate(route: Route?, response: Response): Request? {
|
||||||
|
if (!isProxyEnabled()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (response.request.header(CommonHeaders.PROXY_AUTHORIZATION) != null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val login = settings.proxyLogin ?: return null
|
||||||
|
val password = settings.proxyPassword ?: return null
|
||||||
|
val credential = Credentials.basic(login, password)
|
||||||
|
return response.request.newBuilder()
|
||||||
|
.header(CommonHeaders.PROXY_AUTHORIZATION, credential)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
public override fun getPasswordAuthentication(): PasswordAuthentication? {
|
||||||
|
if (!isProxyEnabled()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val login = settings.proxyLogin ?: return null
|
||||||
|
val password = settings.proxyPassword ?: return null
|
||||||
|
return PasswordAuthentication(login, password.toCharArray())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.ui
|
|
||||||
|
|
||||||
import androidx.lifecycle.LifecycleService
|
|
||||||
|
|
||||||
abstract class BaseService : LifecycleService()
|
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
package org.koitharu.kotatsu.core.ui
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
|
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.core.view.updatePadding
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.commit
|
||||||
|
import com.google.android.material.appbar.AppBarLayout
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.consumeSystemBarsInsets
|
||||||
|
import org.koitharu.kotatsu.databinding.ActivityContainerBinding
|
||||||
|
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
||||||
|
import org.koitharu.kotatsu.main.ui.owners.SnackbarOwner
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
abstract class FragmentContainerActivity(private val fragmentClass: Class<out Fragment>) :
|
||||||
|
BaseActivity<ActivityContainerBinding>(),
|
||||||
|
AppBarOwner,
|
||||||
|
SnackbarOwner {
|
||||||
|
|
||||||
|
override val appBar: AppBarLayout
|
||||||
|
get() = viewBinding.appbar
|
||||||
|
|
||||||
|
override val snackbarHost: CoordinatorLayout
|
||||||
|
get() = viewBinding.root
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContentView(ActivityContainerBinding.inflate(layoutInflater))
|
||||||
|
setDisplayHomeAsUp(true, false)
|
||||||
|
val fm = supportFragmentManager
|
||||||
|
if (fm.findFragmentById(R.id.container) == null) {
|
||||||
|
fm.commit {
|
||||||
|
setReorderingAllowed(true)
|
||||||
|
replace(R.id.container, fragmentClass, getFragmentExtras())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
|
||||||
|
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
viewBinding.appbar.updatePadding(
|
||||||
|
left = bars.left,
|
||||||
|
right = bars.right,
|
||||||
|
top = bars.top,
|
||||||
|
)
|
||||||
|
return insets.consumeSystemBarsInsets(top = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected open fun getFragmentExtras(): Bundle? = intent.extras
|
||||||
|
}
|
||||||
@ -1,58 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.ui.dialog
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.DialogInterface
|
|
||||||
import androidx.annotation.UiContext
|
|
||||||
import androidx.core.net.ConnectivityManagerCompat
|
|
||||||
import dagger.Lazy
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
|
||||||
import org.koitharu.kotatsu.core.prefs.TriStateOption
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.connectivityManager
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
class CommonAlertDialogs @Inject constructor(
|
|
||||||
private val settings: Lazy<AppSettings>,
|
|
||||||
) {
|
|
||||||
|
|
||||||
fun askForDownloadOverMeteredNetwork(
|
|
||||||
@UiContext context: Context,
|
|
||||||
onConfirmed: (allow: Boolean) -> Unit
|
|
||||||
) {
|
|
||||||
when (settings.get().allowDownloadOnMeteredNetwork) {
|
|
||||||
TriStateOption.ENABLED -> onConfirmed(true)
|
|
||||||
TriStateOption.DISABLED -> onConfirmed(false)
|
|
||||||
TriStateOption.ASK -> {
|
|
||||||
if (!ConnectivityManagerCompat.isActiveNetworkMetered(context.connectivityManager)) {
|
|
||||||
onConfirmed(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val listener = DialogInterface.OnClickListener { _, which ->
|
|
||||||
when (which) {
|
|
||||||
DialogInterface.BUTTON_POSITIVE -> {
|
|
||||||
settings.get().allowDownloadOnMeteredNetwork = TriStateOption.ENABLED
|
|
||||||
onConfirmed(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
DialogInterface.BUTTON_NEUTRAL -> {
|
|
||||||
onConfirmed(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
DialogInterface.BUTTON_NEGATIVE -> {
|
|
||||||
settings.get().allowDownloadOnMeteredNetwork = TriStateOption.DISABLED
|
|
||||||
onConfirmed(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BigButtonsAlertDialog.Builder(context)
|
|
||||||
.setIcon(R.drawable.ic_network_cellular)
|
|
||||||
.setTitle(R.string.download_cellular_confirm)
|
|
||||||
.setPositiveButton(R.string.allow_always, listener)
|
|
||||||
.setNeutralButton(R.string.allow_once, listener)
|
|
||||||
.setNegativeButton(R.string.dont_allow, listener)
|
|
||||||
.create()
|
|
||||||
.show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue