Compare commits

...

27 Commits

Author SHA1 Message Date
Koitharu
b916d4016e Fix toolbar icons color 2022-04-03 20:00:13 +03:00
Koitharu
abfd7f281d Fix toolbar icons color 2022-04-03 19:50:58 +03:00
Koitharu
515d6ab2c9 Fix widget size 2022-04-03 11:12:29 +03:00
Koitharu
8ee0dd9930 Fix local pages uri 2022-04-03 11:01:15 +03:00
Koitharu
6b9fad493c Update legacy launcher icon 2022-04-03 10:47:16 +03:00
Koitharu
a21297d209 Fix page saving 2022-04-03 10:05:05 +03:00
Koitharu
db3183c6e2 Fix strings 2022-03-31 19:31:19 +03:00
Koitharu
9eaaf96abe Update translations 2022-03-31 17:39:56 +03:00
Aliaksiej Razumaŭ
365b6a410a Translated using Weblate (Belarusian)
Currently translated at 99.2% (268 of 270 strings)

Co-authored-by: Aliaksiej Razumaŭ <belarusaed@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translation: Kotatsu/Strings
2022-03-31 17:35:43 +03:00
Allan Nordhøy
a6a601c365 Translated using Weblate (Norwegian Bokmål)
Currently translated at 100.0% (270 of 270 strings)

Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/nb_NO/
Translation: Kotatsu/Strings
2022-03-31 17:35:43 +03:00
J. Lavoie
6ae52df8f8 Translated using Weblate (Finnish)
Currently translated at 100.0% (270 of 270 strings)

Translated using Weblate (French)

Currently translated at 100.0% (270 of 270 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (270 of 270 strings)

Translated using Weblate (German)

Currently translated at 100.0% (270 of 270 strings)

Translated using Weblate (Spanish)

Currently translated at 99.6% (269 of 270 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fi/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2022-03-31 17:35:43 +03:00
Anupam Malhotra
993c139715 Translated using Weblate (Spanish)
Currently translated at 99.6% (269 of 270 strings)

Translated using Weblate (Spanish)

Currently translated at 99.6% (266 of 267 strings)

Co-authored-by: Anupam Malhotra <anpm.malhotra@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2022-03-31 17:35:43 +03:00
J. Lavoie
78ca36af11 Translated using Weblate (French)
Currently translated at 100.0% (267 of 267 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (267 of 267 strings)

Translated using Weblate (German)

Currently translated at 100.0% (267 of 267 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2022-03-31 17:35:43 +03:00
Luiz-bro
078d0c9cf9 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (270 of 270 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (267 of 267 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (267 of 267 strings)

Added translation using Weblate (Portuguese (Brazil))

Co-authored-by: Luiz-bro <luiznneto1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2022-03-31 17:35:43 +03:00
kuragehime
40602272da Translated using Weblate (Japanese)
Currently translated at 100.0% (270 of 270 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (267 of 267 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (267 of 267 strings)

Co-authored-by: kuragehime <kuragehime641@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
2022-03-31 17:35:43 +03:00
Oğuz Ersen
570d488bb3 Translated using Weblate (Turkish)
Currently translated at 100.0% (270 of 270 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (269 of 269 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2022-03-31 17:35:43 +03:00
Koitharu
de46cfe7ee Fix manga downloading 2022-03-31 16:18:53 +03:00
Koitharu
8b5a985842 Remove AsyncLayoutInflater; fixes 2022-03-29 20:43:06 +03:00
Koitharu
b57e4c520b Add monochrome icon 2022-03-29 18:45:24 +03:00
Koitharu
ec6b8224ae Update favourite bottom sheet 2022-03-29 08:23:15 +03:00
Koitharu
c48cf83343 Fix default branch selection 2022-03-29 08:13:18 +03:00
Koitharu
0c1ec2b0fc Fixes for api<23 2022-03-28 18:58:32 +03:00
Koitharu
5d2c046d53 Update screenshots 2022-03-27 15:03:49 +03:00
Koitharu
b0f221e5a7 Fixes 2022-03-26 09:07:15 +02:00
Koitharu
85b8bc5d07 Optimize drawables 2022-03-24 06:58:53 +02:00
Koitharu
ae0aa370b2 Update dependencies and fix some warnings 2022-03-24 06:58:53 +02:00
Koitharu
d3e9dc2ea4 Search in chapters #133 2022-03-24 06:58:53 +02:00
215 changed files with 2001 additions and 1177 deletions

View File

@@ -8,10 +8,12 @@ indent_style = tab
insert_final_newline = false
max_line_length = 120
tab_width = 4
# noinspection EditorConfigKeyCorrectness
disabled_rules=no-wildcard-imports,no-unused-imports
[{*.ant,*.fxml,*.jhm,*.jnlp,*.jrxml,*.rng,*.tld,*.wsdl,*.xml,*.xsd,*.xsl,*.xslt,*.xul}]
ij_continuation_indent_size = 4
[{*.gradle.kts,*.kt,*.kts,*.main.kts}]
[{*.kt,*.kts}]
ij_kotlin_allow_trailing_comma = true
ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL

7
.idea/ktlint.xml generated Normal file
View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KtlintProjectConfiguration">
<androidMode>true</androidMode>
<treatAsErrors>false</treatAsErrors>
</component>
</project>

View File

@@ -28,12 +28,12 @@ Download APK from Github Releases:
### Screenshots
| ![Screenshot_20200226-210337](https://github.com/nv95/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/1.png) | ![](https://github.com/nv95/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/2.png) | ![Screenshot_20200226-210232](https://github.com/nv95/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/3.png) |
|---|---|---|
| ![Screenshot_20200226-210405](https://github.com/nv95/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/4.png) | ![Screenshot_20200226-210151](https://github.com/nv95/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/5.png) | ![Screenshot_20200226-210223](https://github.com/nv95/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/6.png) |
| ![Screenshot_20200226-210337](https://github.com/nv95/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/1.png) | ![](https://github.com/nv95/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/2.png) | ![Screenshot_20200226-210232](https://github.com/nv95/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/3.png) |
|-----------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------|
| ![Screenshot_20200226-210405](https://github.com/nv95/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/4.png) | ![Screenshot_20200226-210151](https://github.com/nv95/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/5.png) | ![Screenshot_20200226-210223](https://github.com/nv95/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/6.png) |
| ![](https://github.com/nv95/Kotatsu/raw/devel/metadata/en-US/images/tenInchScreenshots/1.png) | ![](https://github.com/nv95/Kotatsu/raw/devel/metadata/en-US/images/tenInchScreenshots/2.png) |
|---|---|
|-----------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------|
### License
[![GNU GPLv3 Image](https://www.gnu.org/graphics/gplv3-127x51.png)](http://www.gnu.org/licenses/gpl-3.0.en.html)

View File

@@ -6,16 +6,16 @@ plugins {
}
android {
compileSdkVersion 31
buildToolsVersion '30.0.3'
compileSdkVersion 32
buildToolsVersion '32.0.0'
namespace 'org.koitharu.kotatsu'
defaultConfig {
applicationId 'org.koitharu.kotatsu'
minSdkVersion 21
targetSdkVersion 31
versionCode 399
versionName '3.0-alpha1'
targetSdkVersion 32
versionCode 400
versionName '3.0'
generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -49,15 +49,14 @@ android {
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
freeCompilerArgs += [
'-Xjvm-default=enable',
'-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
'-Xopt-in=kotlinx.coroutines.FlowPreview',
'-Xopt-in=kotlin.contracts.ExperimentalContracts',
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
'-opt-in=kotlinx.coroutines.FlowPreview',
'-opt-in=kotlin.contracts.ExperimentalContracts',
]
}
lint {
abortOnError false
disable 'MissingTranslation'
disable 'MissingTranslation', 'PrivateResource'
}
testOptions {
unitTests.includeAndroidResources = true
@@ -66,7 +65,9 @@ android {
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
implementation 'com.github.nv95:kotatsu-parsers:fe243c8acf'
implementation('com.github.nv95:kotatsu-parsers:3ea7e92e64') {
exclude group: 'org.json', module: 'json'
}
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.0'
@@ -83,10 +84,9 @@ dependencies {
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.2.1'
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
implementation 'androidx.asynclayoutinflater:asynclayoutinflater:1.0.0'
implementation 'androidx.preference:preference-ktx:1.2.0'
implementation 'androidx.work:work-runtime-ktx:2.7.1'
implementation 'com.google.android.material:material:1.6.0-alpha03'
implementation 'com.google.android.material:material:1.6.0-beta01'
//noinspection LifecycleAnnotationProcessorWithJava8
kapt 'androidx.lifecycle:lifecycle-compiler:2.4.1'
@@ -108,7 +108,6 @@ dependencies {
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.8.1'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.json:json:20211205'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.0'
testImplementation 'io.insert-koin:koin-test-junit4:3.1.5'

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<bool name="leak_canary_add_launcher_icon">false</bool>
<resources xmlns:tools="http://schemas.android.com/tools">
<bool name="leak_canary_add_launcher_icon" tools:node="replace">false</bool>
</resources>

View File

@@ -8,7 +8,10 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28"
tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application

View File

@@ -42,4 +42,4 @@ abstract class AlertDialogFragment<B : ViewBinding> : DialogFragment() {
protected fun bindingOrNull(): B? = viewBinding
protected abstract fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): B
}
}

View File

@@ -9,11 +9,11 @@ import android.view.ViewGroup.LayoutParams
import androidx.appcompat.app.AppCompatDialog
import androidx.core.view.updateLayoutParams
import androidx.viewbinding.ViewBinding
import com.google.android.material.R as materialR
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import org.koitharu.kotatsu.R
import com.google.android.material.R as materialR
abstract class BaseBottomSheet<B : ViewBinding> : BottomSheetDialogFragment() {
@@ -60,4 +60,4 @@ abstract class BaseBottomSheet<B : ViewBinding> : BottomSheetDialogFragment() {
}
b.isDraggable = !isLocked
}
}
}

View File

@@ -3,11 +3,11 @@ package org.koitharu.kotatsu.base.ui
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
import kotlinx.coroutines.*
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.utils.SingleLiveEvent
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
abstract class BaseViewModel : ViewModel() {
@@ -33,7 +33,7 @@ abstract class BaseViewModel : ViewModel() {
}
}
protected fun createErrorHandler() = CoroutineExceptionHandler { _, throwable ->
private fun createErrorHandler() = CoroutineExceptionHandler { _, throwable ->
if (BuildConfig.DEBUG) {
throwable.printStackTrace()
}

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.base.ui.widgets
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.ColorStateList
import android.content.res.TypedArray
@@ -14,10 +15,12 @@ import androidx.annotation.AttrRes
import androidx.appcompat.widget.AppCompatCheckedTextView
import androidx.core.content.res.use
import androidx.core.content.withStyledAttributes
import com.google.android.material.ripple.RippleUtils
import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel
import org.koitharu.kotatsu.R
@SuppressLint("RestrictedApi")
class ListItemTextView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@@ -33,9 +36,12 @@ class ListItemTextView @JvmOverloads constructor(
init {
context.withStyledAttributes(attrs, R.styleable.ListItemTextView, defStyleAttr) {
val itemRippleColor = getColorStateList(R.styleable.ListItemTextView_rippleColor)
?: getRippleColorFallback(context)
val shape = createShapeDrawable(this)
background = RippleDrawable(
getColorStateList(R.styleable.ListItemTextView_rippleColor) ?: getRippleColorFallback(context),
createShapeDrawable(this),
RippleUtils.sanitizeRippleDrawableColor(itemRippleColor),
shape,
ShapeDrawable(RectShape()),
)
checkedDrawableStart = getDrawable(R.styleable.ListItemTextView_checkedDrawableStart)

View File

@@ -4,7 +4,6 @@ import android.webkit.WebChromeClient
import android.webkit.WebView
import androidx.core.view.isVisible
import com.google.android.material.progressindicator.BaseProgressIndicator
import org.koitharu.kotatsu.utils.ext.setIndeterminateCompat
private const val PROGRESS_MAX = 100
@@ -22,10 +21,10 @@ class ProgressChromeClient(
return
}
if (newProgress in 1 until PROGRESS_MAX) {
progressIndicator.setIndeterminateCompat(false)
progressIndicator.isIndeterminate = false
progressIndicator.setProgressCompat(newProgress.coerceAtMost(PROGRESS_MAX), true)
} else {
progressIndicator.setIndeterminateCompat(true)
progressIndicator.setIndeterminate(true)
}
}
}

View File

@@ -1,6 +1,8 @@
package org.koitharu.kotatsu.core.backup
import android.content.Context
import java.io.File
import java.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
@@ -8,8 +10,6 @@ import org.json.JSONArray
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.utils.MutableZipFile
import org.koitharu.kotatsu.utils.ext.format
import java.io.File
import java.util.*
class BackupArchive(file: File) : MutableZipFile(file) {
@@ -40,7 +40,7 @@ class BackupArchive(file: File) : MutableZipFile(file) {
}
dir.mkdirs()
val filename = buildString {
append(context.getString(R.string.app_name).toLowerCase(Locale.ROOT))
append(context.getString(R.string.app_name).lowercase(Locale.ROOT))
append('_')
append(Date().format("ddMMyyyy"))
append(".bak")

View File

@@ -18,7 +18,8 @@ import org.koitharu.kotatsu.suggestions.data.SuggestionEntity
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class
], version = 9
],
version = 9
)
abstract class MangaDatabase : RoomDatabase() {

View File

@@ -0,0 +1,25 @@
package org.koitharu.kotatsu.core.model
import org.koitharu.kotatsu.parsers.model.Manga
fun Manga.withoutChapters() = if (chapters.isNullOrEmpty()) {
this
} else {
Manga(
id = id,
title = title,
altTitle = altTitle,
url = url,
publicUrl = publicUrl,
rating = rating,
isNsfw = isNsfw,
coverUrl = coverUrl,
tags = tags,
state = state,
author = author,
largeCoverUrl = largeCoverUrl,
description = description,
chapters = null,
source = source,
)
}

View File

@@ -2,13 +2,22 @@ package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel
import android.os.Parcelable
import android.util.Log
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.parsers.model.Manga
class ParcelableManga(
val manga: Manga,
): Parcelable {
) : Parcelable {
constructor(parcel: Parcel) : this(parcel.readManga())
init {
if (BuildConfig.DEBUG && manga.chapters != null) {
Log.w("ParcelableManga", "Passing manga with chapters as Parcelable is dangerous!")
}
}
override fun writeToParcel(parcel: Parcel, flags: Int) {
manga.writeToParcel(parcel, flags)
}

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.network
import java.util.concurrent.TimeUnit
import okhttp3.CookieJar
import okhttp3.OkHttpClient
import org.koin.dsl.bind
@@ -8,8 +9,6 @@ import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.utils.DownloadManagerHelper
import java.util.concurrent.TimeUnit
val networkModule
get() = module {
@@ -28,6 +27,5 @@ val networkModule
}
}.build()
}
factory { DownloadManagerHelper(get(), get()) }
single<MangaLoaderContext> { MangaLoaderContextImpl(get(), get(), get()) }
}
}

View File

@@ -69,7 +69,7 @@ class ShortcutsRepository(
.setLongLabel(manga.title)
.setIcon(icon)
.setIntent(
ReaderActivity.newIntent(context, manga.id, null)
ReaderActivity.newIntent(context, manga.id)
.setAction(ReaderActivity.ACTION_MANGA_READ)
)
}

View File

@@ -3,20 +3,12 @@ package org.koitharu.kotatsu.core.ui
import android.content.Context
import android.content.Intent
import android.util.Log
import java.io.PrintWriter
import java.io.StringWriter
import kotlin.system.exitProcess
class AppCrashHandler(private val applicationContext: Context) : Thread.UncaughtExceptionHandler {
override fun uncaughtException(t: Thread, e: Throwable) {
val crashInfo = buildString {
val writer = StringWriter()
e.printStackTrace(PrintWriter(writer))
append(writer.toString().trimIndent())
}
val intent = Intent(applicationContext, CrashActivity::class.java)
intent.putExtra(Intent.EXTRA_TEXT, crashInfo)
val intent = CrashActivity.newIntent(applicationContext, e)
intent.flags = (Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
try {
applicationContext.startActivity(intent)

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.ui
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
@@ -11,6 +12,7 @@ import android.view.View
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ActivityCrashBinding
import org.koitharu.kotatsu.main.ui.MainActivity
import org.koitharu.kotatsu.parsers.util.ellipsize
import org.koitharu.kotatsu.utils.ShareHelper
class CrashActivity : Activity(), View.OnClickListener {
@@ -63,4 +65,19 @@ class CrashActivity : Activity(), View.OnClickListener {
}
}
}
companion object {
private const val MAX_TRACE_SIZE = 131071
fun newIntent(context: Context, error: Throwable): Intent {
val crashInfo = error
.stackTraceToString()
.trimIndent()
.ellipsize(MAX_TRACE_SIZE)
val intent = Intent(context, CrashActivity::class.java)
intent.putExtra(Intent.EXTRA_TEXT, crashInfo)
return intent
}
}
}

View File

@@ -7,11 +7,11 @@ import android.widget.AdapterView
import android.widget.Spinner
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.SearchView
import androidx.core.graphics.Insets
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.divider.MaterialDividerItemDecoration
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseFragment
@@ -26,11 +26,15 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.utils.RecyclerViewScrollCallback
import kotlin.math.roundToInt
class ChaptersFragment : BaseFragment<FragmentChaptersBinding>(),
class ChaptersFragment :
BaseFragment<FragmentChaptersBinding>(),
OnListItemClickListener<ChapterListItem>,
ActionMode.Callback,
AdapterView.OnItemSelectedListener {
AdapterView.OnItemSelectedListener,
MenuItem.OnActionExpandListener,
SearchView.OnQueryTextListener {
private val viewModel by sharedViewModel<DetailsViewModel>()
@@ -63,6 +67,10 @@ class ChaptersFragment : BaseFragment<FragmentChaptersBinding>(),
viewModel.isChaptersReversed.observe(viewLifecycleOwner) {
activity?.invalidateOptionsMenu()
}
viewModel.hasChapters.observe(viewLifecycleOwner) {
binding.textViewHolder.isGone = it
activity?.invalidateOptionsMenu()
}
}
override fun onDestroyView() {
@@ -75,11 +83,18 @@ class ChaptersFragment : BaseFragment<FragmentChaptersBinding>(),
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
super.onCreateOptionsMenu(menu, inflater)
inflater.inflate(R.menu.opt_chapters, menu)
val searchMenuItem = menu.findItem(R.id.action_search)
searchMenuItem.setOnActionExpandListener(this)
val searchView = searchMenuItem.actionView as SearchView
searchView.setOnQueryTextListener(this)
searchView.setIconifiedByDefault(false)
searchView.queryHint = searchMenuItem.title
}
override fun onPrepareOptionsMenu(menu: Menu) {
super.onPrepareOptionsMenu(menu)
menu.findItem(R.id.action_reversed).isChecked = viewModel.isChaptersReversed.value == true
menu.findItem(R.id.action_search).isVisible = viewModel.hasChapters.value == true
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
@@ -114,10 +129,11 @@ class ChaptersFragment : BaseFragment<FragmentChaptersBinding>(),
)
startActivity(
ReaderActivity.newIntent(
view.context,
viewModel.manga.value ?: return,
ReaderState(item.chapter.id, 0, 0)
), options.toBundle()
context = view.context,
manga = viewModel.manga.value ?: return,
state = ReaderState(item.chapter.id, 0, 0),
),
options.toBundle()
)
}
@@ -189,6 +205,21 @@ class ChaptersFragment : BaseFragment<FragmentChaptersBinding>(),
actionMode = null
}
override fun onMenuItemActionExpand(item: MenuItem?): Boolean = true
override fun onMenuItemActionCollapse(item: MenuItem?): Boolean {
(item?.actionView as? SearchView)?.setQuery("", false)
viewModel.performChapterSearch(null)
return true
}
override fun onQueryTextSubmit(query: String?): Boolean = false
override fun onQueryTextChange(newText: String?): Boolean {
viewModel.performChapterSearch(newText)
return true
}
override fun onWindowInsetsChanged(insets: Insets) {
binding.recyclerViewChapters.updatePadding(
bottom = insets.bottom + (binding.spinnerBranches?.height ?: 0),
@@ -215,7 +246,8 @@ class ChaptersFragment : BaseFragment<FragmentChaptersBinding>(),
if (adapter.itemCount == 0) {
val position = list.indexOfFirst { it.hasFlag(ChapterListItem.FLAG_CURRENT) } - 1
if (position > 0) {
adapter.setItems(list, RecyclerViewScrollCallback(binding.recyclerViewChapters, position))
val offset = (resources.getDimensionPixelSize(R.dimen.chapter_list_item_height) * 0.6).roundToInt()
adapter.setItems(list, RecyclerViewScrollCallback(binding.recyclerViewChapters, position, offset))
} else {
adapter.items = list
}

View File

@@ -1,7 +1,9 @@
package org.koitharu.kotatsu.details.ui
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.Uri
import android.os.Bundle
import android.view.Menu
@@ -52,6 +54,13 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
parametersOf(MangaIntent(intent))
}
private val downloadReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val downloadedManga = DownloadService.getDownloadedManga(intent) ?: return
viewModel.onDownloadComplete(downloadedManga)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityDetailsBinding.inflate(layoutInflater))
@@ -71,6 +80,13 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged)
viewModel.onMangaRemoved.observe(this, ::onMangaRemoved)
viewModel.onError.observe(this, ::onError)
registerReceiver(downloadReceiver, IntentFilter(DownloadService.ACTION_DOWNLOAD_COMPLETE))
}
override fun onDestroy() {
unregisterReceiver(downloadReceiver)
super.onDestroy()
}
private fun onMangaUpdated(manga: Manga) {
@@ -256,9 +272,9 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
setPositiveButton(R.string.read) { _, _ ->
startActivity(
ReaderActivity.newIntent(
this@DetailsActivity,
remoteManga,
ReaderState(chapterId, 0, 0)
context = this@DetailsActivity,
manga = remoteManga,
state = ReaderState(chapterId, 0, 0)
)
)
}

View File

@@ -4,9 +4,7 @@ import android.app.ActivityOptions
import android.os.Bundle
import android.text.Spanned
import android.text.method.LinkMovementMethod
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.*
import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets
import androidx.core.net.toUri
@@ -38,12 +36,20 @@ import org.koitharu.kotatsu.search.ui.SearchActivity
import org.koitharu.kotatsu.utils.FileSize
import org.koitharu.kotatsu.utils.ext.*
class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickListener,
View.OnLongClickListener, ChipsView.OnChipClickListener {
class DetailsFragment :
BaseFragment<FragmentDetailsBinding>(),
View.OnClickListener,
View.OnLongClickListener,
ChipsView.OnChipClickListener {
private val viewModel by sharedViewModel<DetailsViewModel>()
private val coil by inject<ImageLoader>(mode = LazyThreadSafetyMode.NONE)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
override fun onInflateView(
inflater: LayoutInflater,
container: ViewGroup?,
@@ -64,6 +70,11 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
viewModel.readingHistory.observe(viewLifecycleOwner, ::onHistoryChanged)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
super.onCreateOptionsMenu(menu, inflater)
inflater.inflate(R.menu.opt_details_info, menu)
}
private fun onMangaUpdated(manga: Manga) {
with(binding) {
// Main
@@ -177,9 +188,9 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
} else {
startActivity(
ReaderActivity.newIntent(
context ?: return,
manga,
null
context = context ?: return,
manga = manga,
branch = viewModel.selectedBranchValue,
)
)
}
@@ -216,11 +227,14 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
v.showPopupMenu(R.menu.popup_read) {
when (it.itemId) {
R.id.action_read -> {
val branch = viewModel.selectedBranchValue
startActivity(
ReaderActivity.newIntent(
context ?: return@showPopupMenu false,
viewModel.manga.value ?: return@showPopupMenu false,
viewModel.chapters.value?.firstOrNull()?.let { c ->
context = context ?: return@showPopupMenu false,
manga = viewModel.manga.value ?: return@showPopupMenu false,
state = viewModel.chapters.value?.firstOrNull { c ->
c.chapter.branch == branch
}?.let { c ->
ReaderState(c.chapter.id, 0, 0)
}
)
@@ -276,4 +290,4 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
.lifecycle(viewLifecycleOwner)
.enqueueWith(coil)
}
}
}

View File

@@ -7,7 +7,9 @@ import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.base.ui.BaseViewModel
@@ -40,7 +42,7 @@ class DetailsViewModel(
) : BaseViewModel() {
private var loadingJob: Job
private val mangaData = MutableStateFlow<Manga?>(intent.manga)
private val mangaData = MutableStateFlow(intent.manga)
private val selectedBranch = MutableStateFlow<String?>(null)
private val history = mangaData.mapNotNull { it?.id }
@@ -61,7 +63,9 @@ class DetailsViewModel(
trackingRepository.getNewChaptersCount(mangaId)
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0)
private val remoteManga = MutableStateFlow<Manga?>(null)
// Remote manga for saved and saved for remote
private val relatedManga = MutableStateFlow<Manga?>(null)
private val chaptersQuery = MutableStateFlow("")
private val chaptersReversed = settings.observe()
.filter { it == AppSettings.KEY_REVERSE_CHAPTERS }
@@ -93,23 +97,34 @@ class DetailsViewModel(
branches.indexOf(selected)
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
val chapters = combine(
mangaData.map { it?.chapters.orEmpty() },
remoteManga,
history.map { it?.chapterId },
newChapters,
selectedBranch
) { chapters, sourceManga, currentId, newCount, branch ->
val sourceChapters = sourceManga?.chapters
if (sourceManga?.source != MangaSource.LOCAL && !sourceChapters.isNullOrEmpty()) {
mapChaptersWithSource(chapters, sourceChapters, currentId, newCount, branch)
} else {
mapChapters(chapters, sourceChapters, currentId, newCount, branch)
}
}.combine(chaptersReversed) { list, reversed ->
if (reversed) list.asReversed() else list
val hasChapters = mangaData.map {
!(it?.chapters.isNullOrEmpty())
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
val chapters = combine(
combine(
mangaData.map { it?.chapters.orEmpty() },
relatedManga,
history.map { it?.chapterId },
newChapters,
selectedBranch
) { chapters, related, currentId, newCount, branch ->
val relatedChapters = related?.chapters
if (related?.source != MangaSource.LOCAL && !relatedChapters.isNullOrEmpty()) {
mapChaptersWithSource(chapters, relatedChapters, currentId, newCount, branch)
} else {
mapChapters(chapters, relatedChapters, currentId, newCount, branch)
}
},
chaptersReversed,
chaptersQuery,
) { list, reversed, query ->
(if (reversed) list.asReversed() else list).filterSearch(query)
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
val selectedBranchValue: String?
get() = selectedBranch.value
init {
loadingJob = doLoad()
}
@@ -139,7 +154,33 @@ class DetailsViewModel(
}
fun getRemoteManga(): Manga? {
return remoteManga.value
return relatedManga.value?.takeUnless { it.source == MangaSource.LOCAL }
}
fun performChapterSearch(query: String?) {
chaptersQuery.value = query?.trim().orEmpty()
}
fun onDownloadComplete(downloadedManga: Manga) {
val currentManga = mangaData.value ?: return
if (currentManga.id != downloadedManga.id) {
return
}
if (currentManga.source == MangaSource.LOCAL) {
reload()
} else {
viewModelScope.launch(Dispatchers.Default) {
runCatching {
localMangaRepository.getDetails(downloadedManga)
}.onSuccess {
relatedManga.value = it
}.onFailure {
if (BuildConfig.DEBUG) {
it.printStackTrace()
}
}
}
}
}
private fun doLoad() = launchLoadingJob(Dispatchers.Default) {
@@ -155,7 +196,7 @@ class DetailsViewModel(
predictBranch(manga.chapters)
}
mangaData.value = manga
remoteManga.value = runCatching {
relatedManga.value = runCatching {
if (manga.source == MangaSource.LOCAL) {
val m = localMangaRepository.getRemoteManga(manga) ?: return@runCatching null
MangaRepository(m.source).getDetails(m)
@@ -262,4 +303,13 @@ class DetailsViewModel(
}
return groups.maxByOrNull { it.value.size }?.key
}
}
private fun List<ChapterListItem>.filterSearch(query: String): List<ChapterListItem> {
if (query.isEmpty() || this.isEmpty()) {
return this
}
return filter {
it.chapter.name.contains(query, ignoreCase = true)
}
}
}

View File

@@ -4,9 +4,10 @@ import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Rect
import androidx.core.content.ContextCompat
import androidx.core.view.children
import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.utils.ext.getThemeColor
import org.koitharu.kotatsu.R
class ChaptersSelectionDecoration(context: Context) : RecyclerView.ItemDecoration() {
@@ -15,7 +16,7 @@ class ChaptersSelectionDecoration(context: Context) : RecyclerView.ItemDecoratio
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
init {
paint.color = context.getThemeColor(com.google.android.material.R.attr.scrimBackground)
paint.color = ContextCompat.getColor(context, R.color.selector_foreground)
paint.style = Paint.Style.FILL
}

View File

@@ -8,8 +8,8 @@ import coil.ImageLoader
import coil.request.ImageRequest
import coil.size.Scale
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Semaphore
import okhttp3.OkHttpClient
import okhttp3.Request
import okio.IOException
@@ -25,13 +25,16 @@ import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.utils.ext.deleteAwait
import org.koitharu.kotatsu.utils.ext.referer
import org.koitharu.kotatsu.utils.ext.waitForNetwork
import org.koitharu.kotatsu.utils.progress.ProgressJob
import java.io.File
private const val MAX_DOWNLOAD_ATTEMPTS = 3
private const val MAX_PARALLEL_DOWNLOADS = 2
private const val DOWNLOAD_ERROR_DELAY = 500L
private const val TEMP_PAGE_FILE = "page.tmp"
class DownloadManager(
private val coroutineScope: CoroutineScope,
private val context: Context,
private val imageLoader: ImageLoader,
private val okHttp: OkHttpClient,
@@ -39,7 +42,7 @@ class DownloadManager(
private val localMangaRepository: LocalMangaRepository,
) {
private val connectivityManager = context.getSystemService(
private val connectivityManager = context.applicationContext.getSystemService(
Context.CONNECTIVITY_SERVICE
) as ConnectivityManager
private val coverWidth = context.resources.getDimensionPixelSize(
@@ -48,9 +51,29 @@ class DownloadManager(
private val coverHeight = context.resources.getDimensionPixelSize(
androidx.core.R.dimen.compat_notification_large_icon_max_height
)
private val semaphore = Semaphore(MAX_PARALLEL_DOWNLOADS)
fun downloadManga(manga: Manga, chaptersIds: Set<Long>?, startId: Int) = flow<State> {
emit(State.Preparing(startId, manga, null))
fun downloadManga(
manga: Manga,
chaptersIds: Set<Long>?,
startId: Int,
): ProgressJob<DownloadState> {
val stateFlow = MutableStateFlow<DownloadState>(
DownloadState.Queued(startId = startId, manga = manga, cover = null)
)
val job = downloadMangaImpl(manga, chaptersIds, stateFlow, startId)
return ProgressJob(job, stateFlow)
}
private fun downloadMangaImpl(
manga: Manga,
chaptersIds: Set<Long>?,
outState: MutableStateFlow<DownloadState>,
startId: Int,
): Job = coroutineScope.launch(Dispatchers.Default + errorStateHandler(outState)) {
semaphore.acquire()
coroutineContext[WakeLockNode]?.acquire()
outState.value = DownloadState.Preparing(startId, manga, null)
var cover: Drawable? = null
val destination = localMangaRepository.getOutputDir()
checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) }
@@ -67,7 +90,7 @@ class DownloadManager(
.build()
).drawable
}.getOrNull()
emit(State.Preparing(startId, manga, cover))
outState.value = DownloadState.Preparing(startId, manga, cover)
val data = if (manga.chapters == null) repo.getDetails(manga) else manga
output = MangaZip.findInDir(destination, data)
output.prepare(data)
@@ -96,43 +119,43 @@ class DownloadManager(
MimeTypeMap.getFileExtensionFromUrl(url)
)
} catch (e: IOException) {
emit(State.WaitingForNetwork(startId, manga, cover))
outState.value = DownloadState.WaitingForNetwork(startId, data, cover)
connectivityManager.waitForNetwork()
continue@failsafe
}
} while (false)
emit(State.Progress(
startId, manga, cover,
outState.value = DownloadState.Progress(
startId, data, cover,
totalChapters = chapters.size,
currentChapter = chapterIndex,
totalPages = pages.size,
currentPage = pageIndex,
))
)
}
}
}
emit(State.PostProcessing(startId, manga, cover))
outState.value = DownloadState.PostProcessing(startId, data, cover)
if (!output.compress()) {
throw RuntimeException("Cannot create target file")
}
val localManga = localMangaRepository.getFromFile(output.file)
emit(State.Done(startId, manga, cover, localManga))
outState.value = DownloadState.Done(startId, data, cover, localManga)
} catch (_: CancellationException) {
emit(State.Cancelling(startId, manga, cover))
outState.value = DownloadState.Cancelled(startId, manga, cover)
} catch (e: Throwable) {
if (BuildConfig.DEBUG) {
e.printStackTrace()
}
emit(State.Error(startId, manga, cover, e))
outState.value = DownloadState.Error(startId, manga, cover, e)
} finally {
withContext(NonCancellable) {
output?.cleanup()
File(destination, TEMP_PAGE_FILE).deleteAwait()
}
coroutineContext[WakeLockNode]?.release()
semaphore.release()
}
}.catch { e ->
emit(State.Error(startId, manga, null, e))
}
private suspend fun downloadFile(url: String, referer: String, destination: File): File {
@@ -165,71 +188,13 @@ class DownloadManager(
}
}
sealed interface State {
val startId: Int
val manga: Manga
val cover: Drawable?
data class Queued(
override val startId: Int,
override val manga: Manga,
override val cover: Drawable?,
) : State
data class Preparing(
override val startId: Int,
override val manga: Manga,
override val cover: Drawable?,
) : State
data class Progress(
override val startId: Int,
override val manga: Manga,
override val cover: Drawable?,
val totalChapters: Int,
val currentChapter: Int,
val totalPages: Int,
val currentPage: Int,
): State {
val max: Int = totalChapters * totalPages
val progress: Int = totalPages * currentChapter + currentPage + 1
val percent: Float = progress.toFloat() / max
}
data class WaitingForNetwork(
override val startId: Int,
override val manga: Manga,
override val cover: Drawable?,
): State
data class Done(
override val startId: Int,
override val manga: Manga,
override val cover: Drawable?,
val localManga: Manga,
) : State
data class Error(
override val startId: Int,
override val manga: Manga,
override val cover: Drawable?,
val error: Throwable,
) : State
data class Cancelling(
override val startId: Int,
override val manga: Manga,
override val cover: Drawable?,
): State
data class PostProcessing(
override val startId: Int,
override val manga: Manga,
override val cover: Drawable?,
) : State
private fun errorStateHandler(outState: MutableStateFlow<DownloadState>) = CoroutineExceptionHandler { _, throwable ->
val prevValue = outState.value
outState.value = DownloadState.Error(
startId = prevValue.startId,
manga = prevValue.manga,
cover = prevValue.cover,
error = throwable,
)
}
}
}

View File

@@ -0,0 +1,251 @@
package org.koitharu.kotatsu.download.domain
import android.graphics.drawable.Drawable
import org.koitharu.kotatsu.parsers.model.Manga
sealed interface DownloadState {
val startId: Int
val manga: Manga
val cover: Drawable?
class Queued(
override val startId: Int,
override val manga: Manga,
override val cover: Drawable?,
) : DownloadState {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Queued
if (startId != other.startId) return false
if (manga != other.manga) return false
if (cover != other.cover) return false
return true
}
override fun hashCode(): Int {
var result = startId
result = 31 * result + manga.hashCode()
result = 31 * result + (cover?.hashCode() ?: 0)
return result
}
}
class Preparing(
override val startId: Int,
override val manga: Manga,
override val cover: Drawable?,
) : DownloadState {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Preparing
if (startId != other.startId) return false
if (manga != other.manga) return false
if (cover != other.cover) return false
return true
}
override fun hashCode(): Int {
var result = startId
result = 31 * result + manga.hashCode()
result = 31 * result + (cover?.hashCode() ?: 0)
return result
}
}
class Progress(
override val startId: Int,
override val manga: Manga,
override val cover: Drawable?,
val totalChapters: Int,
val currentChapter: Int,
val totalPages: Int,
val currentPage: Int,
) : DownloadState {
val max: Int = totalChapters * totalPages
val progress: Int = totalPages * currentChapter + currentPage + 1
val percent: Float = progress.toFloat() / max
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Progress
if (startId != other.startId) return false
if (manga != other.manga) return false
if (cover != other.cover) return false
if (totalChapters != other.totalChapters) return false
if (currentChapter != other.currentChapter) return false
if (totalPages != other.totalPages) return false
if (currentPage != other.currentPage) return false
return true
}
override fun hashCode(): Int {
var result = startId
result = 31 * result + manga.hashCode()
result = 31 * result + (cover?.hashCode() ?: 0)
result = 31 * result + totalChapters
result = 31 * result + currentChapter
result = 31 * result + totalPages
result = 31 * result + currentPage
return result
}
}
class WaitingForNetwork(
override val startId: Int,
override val manga: Manga,
override val cover: Drawable?,
) : DownloadState {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as WaitingForNetwork
if (startId != other.startId) return false
if (manga != other.manga) return false
if (cover != other.cover) return false
return true
}
override fun hashCode(): Int {
var result = startId
result = 31 * result + manga.hashCode()
result = 31 * result + (cover?.hashCode() ?: 0)
return result
}
}
class Done(
override val startId: Int,
override val manga: Manga,
override val cover: Drawable?,
val localManga: Manga,
) : DownloadState {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Done
if (startId != other.startId) return false
if (manga != other.manga) return false
if (cover != other.cover) return false
if (localManga != other.localManga) return false
return true
}
override fun hashCode(): Int {
var result = startId
result = 31 * result + manga.hashCode()
result = 31 * result + (cover?.hashCode() ?: 0)
result = 31 * result + localManga.hashCode()
return result
}
}
class Error(
override val startId: Int,
override val manga: Manga,
override val cover: Drawable?,
val error: Throwable,
) : DownloadState {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Error
if (startId != other.startId) return false
if (manga != other.manga) return false
if (cover != other.cover) return false
if (error != other.error) return false
return true
}
override fun hashCode(): Int {
var result = startId
result = 31 * result + manga.hashCode()
result = 31 * result + (cover?.hashCode() ?: 0)
result = 31 * result + error.hashCode()
return result
}
}
class Cancelled(
override val startId: Int,
override val manga: Manga,
override val cover: Drawable?,
) : DownloadState {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Cancelled
if (startId != other.startId) return false
if (manga != other.manga) return false
if (cover != other.cover) return false
return true
}
override fun hashCode(): Int {
var result = startId
result = 31 * result + manga.hashCode()
result = 31 * result + (cover?.hashCode() ?: 0)
return result
}
}
class PostProcessing(
override val startId: Int,
override val manga: Manga,
override val cover: Drawable?,
) : DownloadState {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as PostProcessing
if (startId != other.startId) return false
if (manga != other.manga) return false
if (cover != other.cover) return false
return true
}
override fun hashCode(): Int {
var result = startId
result = 31 * result + manga.hashCode()
result = 31 * result + (cover?.hashCode() ?: 0)
return result
}
}
}

View File

@@ -0,0 +1,25 @@
package org.koitharu.kotatsu.download.domain
import android.os.PowerManager
import kotlin.coroutines.AbstractCoroutineContextElement
import kotlin.coroutines.CoroutineContext
class WakeLockNode(
private val wakeLock: PowerManager.WakeLock,
private val timeout: Long,
) : AbstractCoroutineContextElement(Key) {
init {
wakeLock.setReferenceCounted(true)
}
fun acquire() {
wakeLock.acquire(timeout)
}
fun release() {
wakeLock.release()
}
companion object Key : CoroutineContext.Key<WakeLockNode>
}

View File

@@ -9,18 +9,19 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemDownloadBinding
import org.koitharu.kotatsu.download.domain.DownloadManager
import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.utils.ext.*
import org.koitharu.kotatsu.utils.progress.ProgressJob
fun downloadItemAD(
scope: CoroutineScope,
coil: ImageLoader,
) = adapterDelegateViewBinding<ProgressJob<DownloadManager.State>, ProgressJob<DownloadManager.State>, ItemDownloadBinding>(
) = adapterDelegateViewBinding<ProgressJob<DownloadState>, ProgressJob<DownloadState>, ItemDownloadBinding>(
{ inflater, parent -> ItemDownloadBinding.inflate(inflater, parent, false) }
) {
var job: Job? = null
val percentPattern = context.resources.getString(R.string.percent_string_pattern)
bind {
job?.cancel()
@@ -35,62 +36,62 @@ fun downloadItemAD(
}.onEach { state ->
binding.textViewTitle.text = state.manga.title
when (state) {
is DownloadManager.State.Cancelling -> {
is DownloadState.Cancelled -> {
binding.textViewStatus.setText(R.string.cancelling_)
binding.progressBar.setIndeterminateCompat(true)
binding.progressBar.isIndeterminate = true
binding.progressBar.isVisible = true
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
}
is DownloadManager.State.Done -> {
is DownloadState.Done -> {
binding.textViewStatus.setText(R.string.download_complete)
binding.progressBar.setIndeterminateCompat(false)
binding.progressBar.isIndeterminate = false
binding.progressBar.isVisible = false
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
}
is DownloadManager.State.Error -> {
is DownloadState.Error -> {
binding.textViewStatus.setText(R.string.error_occurred)
binding.progressBar.setIndeterminateCompat(false)
binding.progressBar.isIndeterminate = false
binding.progressBar.isVisible = false
binding.textViewPercent.isVisible = false
binding.textViewDetails.text = state.error.getDisplayMessage(context.resources)
binding.textViewDetails.isVisible = true
}
is DownloadManager.State.PostProcessing -> {
is DownloadState.PostProcessing -> {
binding.textViewStatus.setText(R.string.processing_)
binding.progressBar.setIndeterminateCompat(true)
binding.progressBar.isIndeterminate = true
binding.progressBar.isVisible = true
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
}
is DownloadManager.State.Preparing -> {
is DownloadState.Preparing -> {
binding.textViewStatus.setText(R.string.preparing_)
binding.progressBar.setIndeterminateCompat(true)
binding.progressBar.isIndeterminate = true
binding.progressBar.isVisible = true
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
}
is DownloadManager.State.Progress -> {
is DownloadState.Progress -> {
binding.textViewStatus.setText(R.string.manga_downloading_)
binding.progressBar.setIndeterminateCompat(false)
binding.progressBar.isIndeterminate = false
binding.progressBar.isVisible = true
binding.progressBar.max = state.max
binding.progressBar.setProgressCompat(state.progress, true)
binding.textViewPercent.text = (state.percent * 100f).format(1) + "%"
binding.textViewPercent.text = percentPattern.format((state.percent * 100f).format(1))
binding.textViewPercent.isVisible = true
binding.textViewDetails.isVisible = false
}
is DownloadManager.State.Queued -> {
is DownloadState.Queued -> {
binding.textViewStatus.setText(R.string.queued)
binding.progressBar.setIndeterminateCompat(false)
binding.progressBar.isIndeterminate = false
binding.progressBar.isVisible = false
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
}
is DownloadManager.State.WaitingForNetwork -> {
is DownloadState.WaitingForNetwork -> {
binding.textViewStatus.setText(R.string.waiting_for_network)
binding.progressBar.setIndeterminateCompat(false)
binding.progressBar.isIndeterminate = false
binding.progressBar.isVisible = false
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false

View File

@@ -4,13 +4,13 @@ import androidx.recyclerview.widget.DiffUtil
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import kotlinx.coroutines.CoroutineScope
import org.koitharu.kotatsu.download.domain.DownloadManager
import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.utils.progress.ProgressJob
class DownloadsAdapter(
scope: CoroutineScope,
coil: ImageLoader,
) : AsyncListDifferDelegationAdapter<ProgressJob<DownloadManager.State>>(DiffCallback()) {
) : AsyncListDifferDelegationAdapter<ProgressJob<DownloadState>>(DiffCallback()) {
init {
delegatesManager.addDelegate(downloadItemAD(scope, coil))
@@ -21,18 +21,18 @@ class DownloadsAdapter(
return items[position].progressValue.startId.toLong()
}
private class DiffCallback : DiffUtil.ItemCallback<ProgressJob<DownloadManager.State>>() {
private class DiffCallback : DiffUtil.ItemCallback<ProgressJob<DownloadState>>() {
override fun areItemsTheSame(
oldItem: ProgressJob<DownloadManager.State>,
newItem: ProgressJob<DownloadManager.State>,
oldItem: ProgressJob<DownloadState>,
newItem: ProgressJob<DownloadState>,
): Boolean {
return oldItem.progressValue.startId == newItem.progressValue.startId
}
override fun areContentsTheSame(
oldItem: ProgressJob<DownloadManager.State>,
newItem: ProgressJob<DownloadManager.State>,
oldItem: ProgressJob<DownloadState>,
newItem: ProgressJob<DownloadState>,
): Boolean {
return oldItem.progressValue == newItem.progressValue
}

View File

@@ -11,8 +11,9 @@ import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.CrashActivity
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.domain.DownloadManager
import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.download.ui.DownloadsActivity
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.PendingIntentCompat
@@ -20,10 +21,7 @@ import org.koitharu.kotatsu.utils.ext.format
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import com.google.android.material.R as materialR
class DownloadNotification(
private val context: Context,
startId: Int,
) {
class DownloadNotification(private val context: Context, startId: Int) {
private val builder = NotificationCompat.Builder(context, CHANNEL_ID)
private val cancelAction = NotificationCompat.Action(
@@ -47,9 +45,11 @@ class DownloadNotification(
builder.setOnlyAlertOnce(true)
builder.setDefaults(0)
builder.color = ContextCompat.getColor(context, R.color.blue_primary)
builder.foregroundServiceBehavior = NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE
builder.setSilent(true)
}
fun create(state: DownloadManager.State): Notification {
fun create(state: DownloadState): Notification {
builder.setContentTitle(state.manga.title)
builder.setContentText(context.getString(R.string.manga_downloading_))
builder.setProgress(1, 0, true)
@@ -59,13 +59,14 @@ class DownloadNotification(
builder.setLargeIcon(state.cover?.toBitmap())
builder.clearActions()
when (state) {
is DownloadManager.State.Cancelling -> {
is DownloadState.Cancelled -> {
builder.setProgress(1, 0, true)
builder.setContentText(context.getString(R.string.cancelling_))
builder.setContentIntent(null)
builder.setStyle(null)
builder.setOngoing(true)
}
is DownloadManager.State.Done -> {
is DownloadState.Done -> {
builder.setProgress(0, 0, false)
builder.setContentText(context.getString(R.string.download_complete))
builder.setContentIntent(createMangaIntent(context, state.localManga))
@@ -73,40 +74,60 @@ class DownloadNotification(
builder.setSmallIcon(android.R.drawable.stat_sys_download_done)
builder.setCategory(null)
builder.setStyle(null)
builder.setOngoing(false)
}
is DownloadManager.State.Error -> {
is DownloadState.Error -> {
val message = state.error.getDisplayMessage(context.resources)
builder.setProgress(0, 0, false)
builder.setSmallIcon(android.R.drawable.stat_notify_error)
builder.setSubText(context.getString(R.string.error))
builder.setContentText(message)
builder.setAutoCancel(true)
builder.setOngoing(false)
builder.setContentIntent(
PendingIntent.getActivity(
context,
state.manga.hashCode(),
CrashActivity.newIntent(context, state.error),
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
)
builder.setCategory(NotificationCompat.CATEGORY_ERROR)
builder.setStyle(NotificationCompat.BigTextStyle().bigText(message))
}
is DownloadManager.State.PostProcessing -> {
is DownloadState.PostProcessing -> {
builder.setProgress(1, 0, true)
builder.setContentText(context.getString(R.string.processing_))
builder.setStyle(null)
builder.setOngoing(true)
}
is DownloadManager.State.Queued,
is DownloadManager.State.Preparing -> {
is DownloadState.Queued -> {
builder.setProgress(0, 0, false)
builder.setContentText(context.getString(R.string.queued))
builder.setStyle(null)
builder.setOngoing(true)
builder.addAction(cancelAction)
}
is DownloadState.Preparing -> {
builder.setProgress(1, 0, true)
builder.setContentText(context.getString(R.string.preparing_))
builder.setStyle(null)
builder.setOngoing(true)
builder.addAction(cancelAction)
}
is DownloadManager.State.Progress -> {
is DownloadState.Progress -> {
builder.setProgress(state.max, state.progress, false)
builder.setContentText((state.percent * 100).format() + "%")
builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
builder.setStyle(null)
builder.setOngoing(true)
builder.addAction(cancelAction)
}
is DownloadManager.State.WaitingForNetwork -> {
is DownloadState.WaitingForNetwork -> {
builder.setProgress(0, 0, false)
builder.setContentText(context.getString(R.string.waiting_for_network))
builder.setStyle(null)
builder.setOngoing(true)
builder.addAction(cancelAction)
}
}

View File

@@ -8,19 +8,14 @@ import android.os.Binder
import android.os.IBinder
import android.os.PowerManager
import android.widget.Toast
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.isActive
import kotlinx.coroutines.flow.transformWhile
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.plus
import org.koin.android.ext.android.get
import org.koin.core.context.GlobalContext
import org.koitharu.kotatsu.BuildConfig
@@ -28,10 +23,14 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseService
import org.koitharu.kotatsu.base.ui.dialog.CheckBoxAlertDialog
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.model.withoutChapters
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.download.domain.DownloadManager
import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.download.domain.WakeLockNode
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.connectivityManager
import org.koitharu.kotatsu.utils.ext.throttle
import org.koitharu.kotatsu.utils.ext.toArraySet
import org.koitharu.kotatsu.utils.progress.ProgressJob
import java.util.concurrent.TimeUnit
@@ -39,22 +38,27 @@ import kotlin.collections.set
class DownloadService : BaseService() {
private lateinit var notificationManager: NotificationManagerCompat
private lateinit var wakeLock: PowerManager.WakeLock
private lateinit var downloadManager: DownloadManager
private lateinit var notificationSwitcher: ForegroundNotificationSwitcher
private val jobs = LinkedHashMap<Int, ProgressJob<DownloadManager.State>>()
private val jobs = LinkedHashMap<Int, ProgressJob<DownloadState>>()
private val jobCount = MutableStateFlow(0)
private val mutex = Mutex()
private val controlReceiver = ControlReceiver()
private var binder: DownloadBinder? = null
override fun onCreate() {
super.onCreate()
notificationManager = NotificationManagerCompat.from(this)
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager)
notificationSwitcher = ForegroundNotificationSwitcher(this)
val wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading")
downloadManager = DownloadManager(this, get(), get(), get(), get())
downloadManager = DownloadManager(
coroutineScope = lifecycleScope + WakeLockNode(wakeLock, TimeUnit.HOURS.toMillis(1)),
context = this,
imageLoader = get(),
okHttp = get(),
cache = get(),
localMangaRepository = get(),
)
DownloadNotification.createChannel(this)
registerReceiver(controlReceiver, IntentFilter(ACTION_DOWNLOAD_CANCEL))
}
@@ -94,47 +98,50 @@ class DownloadService : BaseService() {
startId: Int,
manga: Manga,
chaptersIds: Set<Long>?,
): ProgressJob<DownloadManager.State> {
val initialState = DownloadManager.State.Queued(startId, manga, null)
val stateFlow = MutableStateFlow<DownloadManager.State>(initialState)
val job = lifecycleScope.launch {
mutex.withLock {
wakeLock.acquire(TimeUnit.HOURS.toMillis(1))
val notification = DownloadNotification(this@DownloadService, startId)
startForeground(startId, notification.create(initialState))
try {
withContext(Dispatchers.Default) {
downloadManager.downloadManga(manga, chaptersIds, startId)
.collect { state ->
stateFlow.value = state
notificationManager.notify(startId, notification.create(state))
}
}
if (stateFlow.value is DownloadManager.State.Done) {
sendBroadcast(
Intent(ACTION_DOWNLOAD_COMPLETE)
.putExtra(EXTRA_MANGA, ParcelableManga(manga))
)
}
} finally {
ServiceCompat.stopForeground(
this@DownloadService,
if (isActive) {
ServiceCompat.STOP_FOREGROUND_DETACH
} else {
ServiceCompat.STOP_FOREGROUND_REMOVE
}
)
if (wakeLock.isHeld) {
wakeLock.release()
}
stopSelf(startId)
}
}
}
return ProgressJob(job, stateFlow)
): ProgressJob<DownloadState> {
val job = downloadManager.downloadManga(manga, chaptersIds, startId)
listenJob(job)
return job
}
private fun listenJob(job: ProgressJob<DownloadState>) {
lifecycleScope.launch {
val startId = job.progressValue.startId
val notification = DownloadNotification(this@DownloadService, startId)
notificationSwitcher.notify(startId, notification.create(job.progressValue))
job.progressAsFlow()
.throttle { state -> if (state is DownloadState.Progress) 400L else 0L }
.whileActive()
.collect { state ->
notificationSwitcher.notify(startId, notification.create(state))
}
job.join()
(job.progressValue as? DownloadState.Done)?.let {
sendBroadcast(
Intent(ACTION_DOWNLOAD_COMPLETE)
.putExtra(EXTRA_MANGA, ParcelableManga(it.localManga.withoutChapters()))
)
}
notificationSwitcher.detach(
startId,
if (job.isCancelled) {
null
} else {
notification.create(job.progressValue)
}
)
stopSelf(startId)
}
}
private fun Flow<DownloadState>.whileActive(): Flow<DownloadState> = transformWhile { state ->
emit(state)
!state.isTerminal
}
private val DownloadState.isTerminal: Boolean
get() = this is DownloadState.Done || this is DownloadState.Error || this is DownloadState.Cancelled
inner class ControlReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
@@ -150,7 +157,7 @@ class DownloadService : BaseService() {
class DownloadBinder(private val service: DownloadService) : Binder() {
val downloads: Flow<Collection<ProgressJob<DownloadManager.State>>>
val downloads: Flow<Collection<ProgressJob<DownloadState>>>
get() = service.jobCount.mapLatest { service.jobs.values }
}
@@ -181,7 +188,14 @@ class DownloadService : BaseService() {
}
fun getCancelIntent(startId: Int) = Intent(ACTION_DOWNLOAD_CANCEL)
.putExtra(ACTION_DOWNLOAD_CANCEL, startId)
.putExtra(EXTRA_CANCEL_ID, startId)
fun getDownloadedManga(intent: Intent?): Manga? {
if (intent?.action == ACTION_DOWNLOAD_COMPLETE) {
return intent.getParcelableExtra<ParcelableManga>(EXTRA_MANGA)?.manga
}
return null
}
private fun confirmDataTransfer(context: Context, callback: () -> Unit) {
val settings = GlobalContext.get().get<AppSettings>()

View File

@@ -0,0 +1,71 @@
package org.koitharu.kotatsu.download.ui.service
import android.app.Notification
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.SparseArray
import androidx.core.app.ServiceCompat
import androidx.core.util.isEmpty
import androidx.core.util.size
private const val DEFAULT_DELAY = 500L
class ForegroundNotificationSwitcher(
private val service: Service,
) {
private val notificationManager = service.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private val notifications = SparseArray<Notification>()
private val handler = Handler(Looper.getMainLooper())
@Synchronized
fun notify(startId: Int, notification: Notification) {
if (notifications.isEmpty()) {
handler.postDelayed(StartForegroundRunnable(startId, notification), DEFAULT_DELAY)
}
notificationManager.notify(startId, notification)
notifications[startId] = notification
}
@Synchronized
fun detach(startId: Int, notification: Notification?) {
notifications.remove(startId)
if (notifications.isEmpty()) {
ServiceCompat.stopForeground(service, ServiceCompat.STOP_FOREGROUND_DETACH)
}
val nextIndex = notifications.size - 1
if (nextIndex >= 0) {
val nextStartId = notifications.keyAt(nextIndex)
val nextNotification = notifications.valueAt(nextIndex)
service.startForeground(nextStartId, nextNotification)
}
handler.postDelayed(NotifyRunnable(startId, notification), DEFAULT_DELAY)
}
private inner class StartForegroundRunnable(
private val startId: Int,
private val notification: Notification,
) : Runnable {
override fun run() {
service.startForeground(startId, notification)
}
}
private inner class NotifyRunnable(
private val startId: Int,
private val notification: Notification?,
) : Runnable {
override fun run() {
if (notification != null) {
notificationManager.notify(startId, notification)
} else {
notificationManager.cancel(startId)
}
}
}
}

View File

@@ -22,8 +22,10 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.withArgs
class FavouriteCategoriesDialog : BaseBottomSheet<DialogFavoriteCategoriesBinding>(),
OnListItemClickListener<MangaCategoryItem>, CategoriesEditDelegate.CategoriesEditCallback,
class FavouriteCategoriesDialog :
BaseBottomSheet<DialogFavoriteCategoriesBinding>(),
OnListItemClickListener<MangaCategoryItem>,
CategoriesEditDelegate.CategoriesEditCallback,
View.OnClickListener {
private val viewModel by viewModel<MangaCategoriesViewModel> {

View File

@@ -2,13 +2,13 @@ package org.koitharu.kotatsu.favourites.ui.categories.select.adapter
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.databinding.ItemCategoryCheckableBinding
import org.koitharu.kotatsu.databinding.ItemCheckableNewBinding
import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem
fun mangaCategoryAD(
clickListener: OnListItemClickListener<MangaCategoryItem>
) = adapterDelegateViewBinding<MangaCategoryItem, MangaCategoryItem, ItemCategoryCheckableBinding>(
{ inflater, parent -> ItemCategoryCheckableBinding.inflate(inflater, parent, false) }
) = adapterDelegateViewBinding<MangaCategoryItem, MangaCategoryItem, ItemCheckableNewBinding>(
{ inflater, parent -> ItemCheckableNewBinding.inflate(inflater, parent, false) }
) {
itemView.setOnClickListener {
@@ -16,7 +16,7 @@ fun mangaCategoryAD(
}
bind {
with(binding.checkedTextView) {
with(binding.root) {
text = item.name
isChecked = item.isChecked
}

View File

@@ -24,7 +24,6 @@ import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.list.ui.adapter.AsyncViewFactory
import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter
import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter.Companion.ITEM_TYPE_MANGA_GRID
import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
@@ -35,12 +34,10 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.utils.ext.*
private const val PREFETCH_ITEM_LIST = 10
private const val PREFETCH_ITEM_DETAILED = 8
private const val PREFETCH_ITEM_GRID = 16
abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
PaginationScrollListener.Callback, MangaListListener,
abstract class MangaListFragment :
BaseFragment<FragmentListBinding>(),
PaginationScrollListener.Callback,
MangaListListener,
SwipeRefreshLayout.OnRefreshListener {
private var listAdapter: MangaListAdapter? = null
@@ -50,7 +47,6 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
private val listCommitCallback = Runnable {
spanSizeLookup.invalidateCache()
}
private var asyncViewFactory: AsyncViewFactory? = null
open val isSwipeRefreshEnabled = true
protected abstract val viewModel: MangaListViewModel
@@ -67,12 +63,10 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
asyncViewFactory = AsyncViewFactory(binding.recyclerView)
listAdapter = MangaListAdapter(
coil = get(),
lifecycleOwner = viewLifecycleOwner,
listener = this,
viewFactory = checkNotNull(asyncViewFactory),
)
paginationListener = PaginationScrollListener(4, this)
with(binding.recyclerView) {
@@ -97,8 +91,6 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
override fun onDestroyView() {
listAdapter = null
paginationListener = null
asyncViewFactory?.clear()
asyncViewFactory = null
spanSizeLookup.invalidateCache()
super.onDestroyView()
}
@@ -172,7 +164,7 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
@CallSuper
protected open fun onLoadingStateChanged(isLoading: Boolean) {
binding.swipeRefreshLayout.isEnabled = binding.swipeRefreshLayout.isRefreshing ||
isSwipeRefreshEnabled && !isLoading
isSwipeRefreshEnabled && !isLoading
if (!isLoading) {
binding.swipeRefreshLayout.isRefreshing = false
}
@@ -194,6 +186,10 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
headerHeight + resources.resolveDp(-72),
headerHeight + resources.resolveDp(10),
)
} else {
binding.recyclerView.updatePadding(
bottom = insets.bottom,
)
}
}
@@ -219,26 +215,18 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
with(binding.recyclerView) {
clearItemDecorations()
removeOnLayoutChangeListener(spanResolver)
asyncViewFactory?.clear()
val isListPending = viewModel.isListPending()
when (mode) {
ListMode.LIST -> {
layoutManager = FitHeightLinearLayoutManager(context)
val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing)
addItemDecoration(SpacingItemDecoration(spacing))
updatePadding(left = spacing, right = spacing)
if (isListPending) {
asyncViewFactory?.prefetch(R.layout.item_manga_list, PREFETCH_ITEM_LIST)
}
}
ListMode.DETAILED_LIST -> {
layoutManager = FitHeightLinearLayoutManager(context)
val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing)
updatePadding(left = spacing, right = spacing)
addItemDecoration(SpacingItemDecoration(spacing))
if (isListPending) {
asyncViewFactory?.prefetch(R.layout.item_manga_list_details, PREFETCH_ITEM_DETAILED)
}
}
ListMode.GRID -> {
layoutManager = FitHeightGridLayoutManager(context, spanResolver.spanCount).also {
@@ -248,9 +236,6 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
addItemDecoration(SpacingItemDecoration(spacing))
updatePadding(left = spacing, right = spacing)
addOnLayoutChangeListener(spanResolver)
if (isListPending) {
asyncViewFactory?.prefetch(R.layout.item_manga_grid, PREFETCH_ITEM_GRID)
}
}
}
}

View File

@@ -44,10 +44,4 @@ abstract class MangaListViewModel(
abstract fun onRefresh()
abstract fun onRetry()
fun isListPending(): Boolean {
return content.value?.any {
it is MangaListModel || it is MangaGridModel || it is MangaListDetailedModel
} != true
}
}

View File

@@ -1,58 +0,0 @@
package org.koitharu.kotatsu.list.ui.adapter
import android.util.Log
import android.util.SparseArray
import android.view.View
import android.view.ViewGroup
import androidx.annotation.LayoutRes
import androidx.asynclayoutinflater.view.AsyncLayoutInflater
import androidx.core.util.valueIterator
import org.koitharu.kotatsu.BuildConfig
import java.util.*
class AsyncViewFactory(private val parent: ViewGroup) : AsyncLayoutInflater.OnInflateFinishedListener {
private val asyncInflater = AsyncLayoutInflater(parent.context)
private val pool = SparseArray<LinkedList<View>>()
override fun onInflateFinished(view: View, resid: Int, parent: ViewGroup?) {
var list = pool.get(resid)
if (list != null) {
list.addLast(view)
} else {
list = LinkedList()
list.add(view)
pool.put(resid, list)
}
}
fun clear() {
if (BuildConfig.DEBUG) {
pool.valueIterator().forEach {
if (it.isNotEmpty()) {
Log.w("AsyncViewFactory", "You have ${it.size} unconsumed prefetched items")
}
}
}
pool.clear()
}
fun prefetch(@LayoutRes resId: Int, count: Int) {
if (count <= 0) return
repeat(count) {
asyncInflater.inflate(resId, parent, this)
}
}
operator fun get(@LayoutRes resId: Int): View? {
val result = pool.get(resId)?.removeFirstOrNull()
if (BuildConfig.DEBUG && result == null) {
Log.w("AsyncViewFactory", "Item requested but missing")
}
return result
}
fun getCount(@LayoutRes resId: Int): Int {
return pool[resId]?.size ?: 0
}
}

View File

@@ -20,15 +20,8 @@ fun mangaGridItemAD(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener<Manga>,
viewFactory: AsyncViewFactory,
) = adapterDelegateViewBinding<MangaGridModel, ListModel, ItemMangaGridBinding>(
{ inflater, parent ->
viewFactory[R.layout.item_manga_grid]?.let {
ItemMangaGridBinding.bind(it)
} ?: run {
ItemMangaGridBinding.inflate(inflater, parent, false)
}
}
{ inflater, parent -> ItemMangaGridBinding.inflate(inflater, parent, false) }
) {
var imageRequest: Disposable? = null

View File

@@ -12,20 +12,13 @@ class MangaListAdapter(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
listener: MangaListListener,
viewFactory: AsyncViewFactory,
) : AsyncListDifferDelegationAdapter<ListModel>(DiffCallback()) {
init {
delegatesManager
.addDelegate(
ITEM_TYPE_MANGA_LIST,
mangaListItemAD(coil, lifecycleOwner, listener, viewFactory)
)
.addDelegate(
ITEM_TYPE_MANGA_LIST_DETAILED,
mangaListDetailedItemAD(coil, lifecycleOwner, listener, viewFactory)
)
.addDelegate(ITEM_TYPE_MANGA_GRID, mangaGridItemAD(coil, lifecycleOwner, listener, viewFactory))
.addDelegate(ITEM_TYPE_MANGA_LIST, mangaListItemAD(coil, lifecycleOwner, listener))
.addDelegate(ITEM_TYPE_MANGA_LIST_DETAILED, mangaListDetailedItemAD(coil, lifecycleOwner, listener))
.addDelegate(ITEM_TYPE_MANGA_GRID, mangaGridItemAD(coil, lifecycleOwner, listener))
.addDelegate(ITEM_TYPE_LOADING_FOOTER, loadingFooterAD())
.addDelegate(ITEM_TYPE_LOADING_STATE, loadingStateAD())
.addDelegate(ITEM_TYPE_DATE, relatedDateItemAD())

View File

@@ -21,15 +21,8 @@ fun mangaListDetailedItemAD(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener<Manga>,
viewFactory: AsyncViewFactory,
) = adapterDelegateViewBinding<MangaListDetailedModel, ListModel, ItemMangaListDetailsBinding>(
{ inflater, parent ->
viewFactory[R.layout.item_manga_list_details]?.let {
ItemMangaListDetailsBinding.bind(it)
} ?: run {
ItemMangaListDetailsBinding.inflate(inflater, parent, false)
}
}
{ inflater, parent -> ItemMangaListDetailsBinding.inflate(inflater, parent, false) }
) {
var imageRequest: Disposable? = null

View File

@@ -21,15 +21,8 @@ fun mangaListItemAD(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener<Manga>,
viewFactory: AsyncViewFactory,
) = adapterDelegateViewBinding<MangaListModel, ListModel, ItemMangaListBinding>(
{ inflater, parent ->
viewFactory[R.layout.item_manga_list]?.let {
ItemMangaListBinding.bind(it)
} ?: run {
ItemMangaListBinding.inflate(inflater, parent, false)
}
}
{ inflater, parent -> ItemMangaListBinding.inflate(inflater, parent, false) }
) {
var imageRequest: Disposable? = null

View File

@@ -6,6 +6,7 @@ import org.koin.dsl.module
import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.local.ui.LocalListViewModel
import org.koitharu.kotatsu.utils.ExternalStorageHelper
val localModule
get() = module {
@@ -13,5 +14,7 @@ val localModule
single { LocalStorageManager(androidContext(), get()) }
single { LocalMangaRepository(get()) }
factory { ExternalStorageHelper(androidContext()) }
viewModel { LocalListViewModel(get(), get(), get(), get()) }
}

View File

@@ -46,9 +46,13 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
return files.mapNotNull { x -> runCatching { getFromFile(x) }.getOrNull() }
}
override suspend fun getDetails(manga: Manga) = if (manga.chapters == null) {
getFromFile(Uri.parse(manga.url).toFile())
} else manga
override suspend fun getDetails(manga: Manga) = when {
manga.source != MangaSource.LOCAL -> requireNotNull(findSavedManga(manga)) {
"Manga is not local or saved"
}
manga.chapters == null -> getFromFile(Uri.parse(manga.url).toFile())
else -> manga
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
return runInterruptible(Dispatchers.IO){
@@ -102,8 +106,7 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
url = fileUri,
coverUrl = zipUri(
file,
entryName = index.getCoverEntry()
?: findFirstEntry(zip.entries(), isImage = true)?.name.orEmpty()
entryName = index.getCoverEntry() ?: findFirstImageEntry(zip.entries())?.name.orEmpty()
),
chapters = info.chapters?.map { c ->
c.copy(url = fileUri, source = MangaSource.LOCAL)
@@ -125,7 +128,7 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
url = fileUri,
publicUrl = fileUri,
source = MangaSource.LOCAL,
coverUrl = zipUri(file, findFirstEntry(zip.entries(), isImage = true)?.name.orEmpty()),
coverUrl = zipUri(file, findFirstImageEntry(zip.entries())?.name.orEmpty()),
chapters = chapters.sortedWith(AlphanumComparator()).mapIndexed { i, s ->
MangaChapter(
id = "$i$s".longHashCode(),
@@ -184,20 +187,16 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
}
}
private fun zipUri(file: File, entryName: String) = Uri.fromParts("cbz", file.path, entryName).toString()
private fun zipUri(file: File, entryName: String) = "cbz://${file.path}#$entryName"
private fun findFirstEntry(entries: Enumeration<out ZipEntry>, isImage: Boolean): ZipEntry? {
private fun findFirstImageEntry(entries: Enumeration<out ZipEntry>): ZipEntry? {
val list = entries.toList()
.filterNot { it.isDirectory }
.sortedWith(compareBy(AlphanumComparator()) { x -> x.name })
return if (isImage) {
val map = MimeTypeMap.getSingleton()
list.firstOrNull {
map.getMimeTypeFromExtension(it.name.substringAfterLast('.'))
?.startsWith("image/") == true
}
} else {
list.firstOrNull()
val map = MimeTypeMap.getSingleton()
return list.firstOrNull {
map.getMimeTypeFromExtension(it.name.substringAfterLast('.'))
?.startsWith("image/") == true
}
}

View File

@@ -58,11 +58,13 @@ import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker
import org.koitharu.kotatsu.tracker.ui.FeedFragment
import org.koitharu.kotatsu.tracker.work.TrackWorker
import org.koitharu.kotatsu.utils.ext.*
import com.google.android.material.R as materialR
private const val TAG_PRIMARY = "primary"
private const val TAG_SEARCH = "search"
class MainActivity : BaseActivity<ActivityMainBinding>(),
class MainActivity :
BaseActivity<ActivityMainBinding>(),
NavigationView.OnNavigationItemSelectedListener,
AppBarOwner,
View.OnClickListener,
@@ -92,7 +94,9 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
R.string.open_menu,
R.string.close_menu
).apply {
setHomeAsUpIndicator(ContextCompat.getDrawable(this@MainActivity, R.drawable.ic_arrow_back))
setHomeAsUpIndicator(
ContextCompat.getDrawable(this@MainActivity, materialR.drawable.abc_ic_ab_back_material)
)
setToolbarNavigationClickListener {
binding.searchView.hideKeyboard()
onBackPressed()
@@ -106,7 +110,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
onFocusChangeListener = this@MainActivity
searchSuggestionListener = this@MainActivity
if (drawer == null) {
drawableStart = ContextCompat.getDrawable(context, R.drawable.ic_search)
drawableStart = context.getThemeDrawable(materialR.attr.actionModeWebSearchDrawable)
}
}
@@ -294,7 +298,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
binding.fab, 0, 0, binding.fab.measuredWidth, binding.fab.measuredHeight
)
}
startActivity(ReaderActivity.newIntent(this, manga, null), options?.toBundle())
startActivity(ReaderActivity.newIntent(this, manga), options?.toBundle())
}
private fun onError(e: Throwable) {
@@ -305,11 +309,13 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
private fun onLoadingStateChanged(isLoading: Boolean) {
binding.fab.isEnabled = !isLoading
if (isLoading) {
binding.fab.setImageDrawable(CircularProgressDrawable(this).also {
it.setColorSchemeColors(R.color.kotatsu_onPrimaryContainer)
it.strokeWidth = resources.resolveDp(3.5f)
it.start()
})
binding.fab.setImageDrawable(
CircularProgressDrawable(this).also {
it.setColorSchemeColors(R.color.kotatsu_onPrimaryContainer)
it.strokeWidth = resources.resolveDp(3.5f)
it.start()
}
)
} else {
binding.fab.setImageResource(R.drawable.ic_read_fill)
}

View File

@@ -13,6 +13,15 @@ val readerModule
single { PagesCache(get()) }
viewModel { params ->
ReaderViewModel(params[0], params[1], get(), get(), get(), get(), get())
ReaderViewModel(
intent = params[0],
initialState = params[1],
preselectedBranch = params[2],
dataRepository = get(),
historyRepository = get(),
shortcutsRepository = get(),
settings = get(),
externalStorageHelper = get(),
)
}
}

View File

@@ -6,6 +6,10 @@ import android.graphics.BitmapFactory
import android.net.Uri
import androidx.collection.LongSparseArray
import androidx.collection.set
import java.io.File
import java.util.*
import java.util.concurrent.atomic.AtomicInteger
import java.util.zip.ZipFile
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -27,10 +31,6 @@ import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.utils.ext.connectivityManager
import org.koitharu.kotatsu.utils.progress.ProgressDeferred
import java.io.File
import java.util.*
import java.util.concurrent.atomic.AtomicInteger
import java.util.zip.ZipFile
private const val PROGRESS_UNDEFINED = -1f
private const val PREFETCH_LIMIT_DEFAULT = 10
@@ -77,7 +77,7 @@ class PageLoader : KoinComponent, Closeable {
}
}
fun loadPageAsync(page: MangaPage, force: Boolean) : ProgressDeferred<File, Float> {
fun loadPageAsync(page: MangaPage, force: Boolean): ProgressDeferred<File, Float> {
if (!force) {
cache[page.url]?.let {
return getCompletedTask(it)

View File

@@ -5,8 +5,6 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.FragmentManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.divider.MaterialDividerItemDecoration
import org.koin.android.ext.android.get
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseBottomSheet
@@ -21,6 +19,7 @@ import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.utils.BottomSheetToolbarController
import org.koitharu.kotatsu.utils.RecyclerViewScrollCallback
import org.koitharu.kotatsu.utils.ext.withArgs
import kotlin.math.roundToInt
class ChaptersBottomSheet : BaseBottomSheet<SheetChaptersBinding>(), OnListItemClickListener<ChapterListItem> {
@@ -35,9 +34,6 @@ class ChaptersBottomSheet : BaseBottomSheet<SheetChaptersBinding>(), OnListItemC
if (!resources.getBoolean(R.bool.is_tablet)) {
binding.toolbar.navigationIcon = null
}
binding.recyclerView.addItemDecoration(
MaterialDividerItemDecoration(view.context, RecyclerView.VERTICAL)
)
val chapters = arguments?.getParcelable<ParcelableMangaChapters>(ARG_CHAPTERS)?.chapters
if (chapters.isNullOrEmpty()) {
dismissAllowingStateLoss()
@@ -59,7 +55,8 @@ class ChaptersBottomSheet : BaseBottomSheet<SheetChaptersBinding>(), OnListItemC
binding.recyclerView.adapter = ChaptersAdapter(this).also { adapter ->
if (currentPosition >= 0) {
val targetPosition = (currentPosition - 1).coerceAtLeast(0)
adapter.setItems(items, RecyclerViewScrollCallback(binding.recyclerView, targetPosition))
val offset = (resources.getDimensionPixelSize(R.dimen.chapter_list_item_height) * 0.6).roundToInt()
adapter.setItems(items, RecyclerViewScrollCallback(binding.recyclerView, targetPosition, offset))
} else {
adapter.items = items
}

View File

@@ -0,0 +1,26 @@
package org.koitharu.kotatsu.reader.ui
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Environment
import android.provider.DocumentsContract
import android.webkit.MimeTypeMap
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.net.toUri
class PageSaveContract : ActivityResultContracts.CreateDocument() {
override fun createIntent(context: Context, input: String): Intent {
val intent = super.createIntent(context, input)
intent.type = MimeTypeMap.getSingleton()
.getMimeTypeFromExtension(input.substringAfterLast('.')) ?: "image/*"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
intent.putExtra(
DocumentsContract.EXTRA_INITIAL_URI,
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES).toUri(),
)
}
return intent
}
}

View File

@@ -1,18 +1,15 @@
package org.koitharu.kotatsu.reader.ui
import android.Manifest
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
import android.view.*
import android.widget.Toast
import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets
import androidx.core.net.toUri
import androidx.core.view.*
import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope
@@ -54,22 +51,28 @@ import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.hasGlobalPoint
import org.koitharu.kotatsu.utils.ext.observeWithPrevious
class ReaderActivity : BaseFullscreenActivity<ActivityReaderBinding>(),
class ReaderActivity :
BaseFullscreenActivity<ActivityReaderBinding>(),
ChaptersBottomSheet.OnChapterChangeListener,
GridTouchHelper.OnGridTouchListener, OnPageSelectListener, ReaderConfigDialog.Callback,
ActivityResultCallback<Boolean>, ReaderControlDelegate.OnInteractionListener, OnApplyWindowInsetsListener {
GridTouchHelper.OnGridTouchListener,
OnPageSelectListener,
ReaderConfigDialog.Callback,
ActivityResultCallback<Uri?>,
ReaderControlDelegate.OnInteractionListener,
OnApplyWindowInsetsListener {
private val viewModel by viewModel<ReaderViewModel> {
parametersOf(MangaIntent(intent), intent?.getParcelableExtra<ReaderState>(EXTRA_STATE))
parametersOf(
MangaIntent(intent),
intent?.getParcelableExtra<ReaderState>(EXTRA_STATE),
intent?.getStringExtra(EXTRA_BRANCH),
)
}
private lateinit var touchHelper: GridTouchHelper
private lateinit var orientationHelper: ScreenOrientationHelper
private lateinit var controlDelegate: ReaderControlDelegate
private val permissionsRequest = registerForActivityResult(
ActivityResultContracts.RequestPermission(),
this
)
private val savePageRequest = registerForActivityResult(PageSaveContract(), this)
private var gestureInsets: Insets = Insets.NONE
private val reader
@@ -142,7 +145,8 @@ class ReaderActivity : BaseFullscreenActivity<ActivityReaderBinding>(),
when (item.itemId) {
R.id.action_reader_mode -> {
ReaderConfigDialog.show(
supportFragmentManager, when (reader) {
supportFragmentManager,
when (reader) {
is PagerReaderFragment -> ReaderMode.STANDARD
is WebtoonReaderFragment -> ReaderMode.WEBTOON
is ReversedReaderFragment -> ReaderMode.REVERSED
@@ -180,29 +184,22 @@ class ReaderActivity : BaseFullscreenActivity<ActivityReaderBinding>(),
}
}
R.id.action_save_page -> {
if (!viewModel.content.value?.pages.isNullOrEmpty()) {
if (ContextCompat.checkSelfPermission(
this,
Manifest.permission.WRITE_EXTERNAL_STORAGE
) == PackageManager.PERMISSION_GRANTED
) {
onActivityResult(true)
} else {
permissionsRequest.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
viewModel.getCurrentPage()?.also { page ->
viewModel.saveCurrentState(reader?.getCurrentState())
val name = page.url.toUri().run {
fragment ?: lastPathSegment ?: ""
}
} else {
showWaitWhileLoading()
}
savePageRequest.launch(name)
} ?: showWaitWhileLoading()
}
else -> return super.onOptionsItemSelected(item)
}
return true
}
override fun onActivityResult(result: Boolean) {
if (result) {
viewModel.saveCurrentState(reader?.getCurrentState())
viewModel.saveCurrentPage()
override fun onActivityResult(uri: Uri?) {
if (uri != null) {
viewModel.saveCurrentPage(uri)
}
}
@@ -286,7 +283,7 @@ class ReaderActivity : BaseFullscreenActivity<ActivityReaderBinding>(),
private fun onPageSaved(uri: Uri?) {
if (uri != null) {
Snackbar.make(binding.container, R.string.page_saved, Snackbar.LENGTH_INDEFINITE)
Snackbar.make(binding.container, R.string.page_saved, Snackbar.LENGTH_LONG)
.setAnchorView(binding.appbarBottom)
.setAction(R.string.share) {
ShareHelper(this).shareImage(uri)
@@ -407,18 +404,29 @@ class ReaderActivity : BaseFullscreenActivity<ActivityReaderBinding>(),
const val ACTION_MANGA_READ = "${BuildConfig.APPLICATION_ID}.action.READ_MANGA"
private const val EXTRA_STATE = "state"
private const val EXTRA_BRANCH = "branch"
private const val TOAST_DURATION = 1500L
fun newIntent(context: Context, manga: Manga): Intent {
return Intent(context, ReaderActivity::class.java)
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga))
}
fun newIntent(context: Context, manga: Manga, branch: String?): Intent {
return Intent(context, ReaderActivity::class.java)
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga))
.putExtra(EXTRA_BRANCH, branch)
}
fun newIntent(context: Context, manga: Manga, state: ReaderState?): Intent {
return Intent(context, ReaderActivity::class.java)
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga))
.putExtra(EXTRA_STATE, state)
}
fun newIntent(context: Context, mangaId: Long, state: ReaderState?): Intent {
fun newIntent(context: Context, mangaId: Long): Intent {
return Intent(context, ReaderActivity::class.java)
.putExtra(MangaIntent.KEY_ID, mangaId)
.putExtra(EXTRA_STATE, state)
}
}
}
}

View File

@@ -20,8 +20,10 @@ data class ReaderState(
scroll = history.scroll
)
fun initial(manga: Manga) = ReaderState(
chapterId = manga.chapters?.firstOrNull()?.id ?: error("Cannot find first chapter"),
fun initial(manga: Manga, branch: String?) = ReaderState(
chapterId = manga.chapters?.firstOrNull {
it.branch == branch
}?.id ?: error("Cannot find first chapter"),
page = 0,
scroll = 0
)

View File

@@ -8,6 +8,7 @@ import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.base.domain.MangaUtils
@@ -25,7 +26,7 @@ import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
import org.koitharu.kotatsu.utils.DownloadManagerHelper
import org.koitharu.kotatsu.utils.ExternalStorageHelper
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.IgnoreErrors
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
@@ -34,11 +35,12 @@ import org.koitharu.kotatsu.utils.ext.processLifecycleScope
class ReaderViewModel(
private val intent: MangaIntent,
initialState: ReaderState?,
private val preselectedBranch: String?,
private val dataRepository: MangaDataRepository,
private val historyRepository: HistoryRepository,
private val shortcutsRepository: ShortcutsRepository,
private val settings: AppSettings,
private val downloadManagerHelper: DownloadManagerHelper,
private val externalStorageHelper: ExternalStorageHelper,
) : BaseViewModel() {
private var loadingJob: Job? = null
@@ -135,25 +137,29 @@ class ReaderViewModel(
return pages.filter { it.chapterId == chapterId }.map { it.toMangaPage() }
}
fun saveCurrentPage() {
fun saveCurrentPage(destination: Uri) {
launchJob(Dispatchers.Default) {
try {
val state = currentState.value ?: error("Undefined state")
val page = content.value?.pages?.find {
it.chapterId == state.chapterId && it.index == state.page
}?.toMangaPage() ?: error("Page not found")
val repo = MangaRepository(page.source)
val pageUrl = repo.getPageUrl(page)
val downloadId = downloadManagerHelper.downloadPage(page, pageUrl)
val uri = downloadManagerHelper.awaitDownload(downloadId)
onPageSaved.postCall(uri)
val page = getCurrentPage() ?: error("Page not found")
externalStorageHelper.savePage(page, destination)
onPageSaved.postCall(destination)
} catch (_: CancellationException) {
} catch (e: Exception) {
if (BuildConfig.DEBUG) {
e.printStackTrace()
}
onPageSaved.postCall(null)
}
}
}
fun getCurrentPage(): MangaPage? {
val state = currentState.value ?: return null
return content.value?.pages?.find {
it.chapterId == state.chapterId && it.index == state.page
}?.toMangaPage()
}
fun switchChapter(id: Long) {
val prevJob = loadingJob
loadingJob = launchLoadingJob(Dispatchers.Default) {
@@ -188,8 +194,7 @@ class ReaderViewModel(
private fun loadImpl() {
loadingJob = launchLoadingJob(Dispatchers.Default) {
var manga = dataRepository.resolveIntent(intent)
?: throw MangaNotFoundException("Cannot find manga")
var manga = dataRepository.resolveIntent(intent) ?: throw MangaNotFoundException("Cannot find manga")
mangaData.value = manga
val repo = MangaRepository(manga.source)
manga = repo.getDetails(manga)
@@ -197,21 +202,20 @@ class ReaderViewModel(
chapters.put(it.id, it)
}
// determine mode
val mode =
dataRepository.getReaderMode(manga.id) ?: manga.chapters?.randomOrNull()?.let {
val pages = repo.getPages(it)
val isWebtoon = MangaUtils.determineMangaIsWebtoon(pages)
val newMode = getReaderMode(isWebtoon)
if (isWebtoon != null) {
dataRepository.savePreferences(manga, newMode)
}
newMode
} ?: error("There are no chapters in this manga")
val mode = dataRepository.getReaderMode(manga.id) ?: manga.chapters?.randomOrNull()?.let {
val pages = repo.getPages(it)
val isWebtoon = MangaUtils.determineMangaIsWebtoon(pages)
val newMode = getReaderMode(isWebtoon)
if (isWebtoon != null) {
dataRepository.savePreferences(manga, newMode)
}
newMode
} ?: error("There are no chapters in this manga")
// obtain state
if (currentState.value == null) {
currentState.value = historyRepository.getOne(manga)?.let {
ReaderState.from(it)
} ?: ReaderState.initial(manga)
} ?: ReaderState.initial(manga, preselectedBranch)
}
val branch = chapters[currentState.value?.chapterId ?: 0L].branch
@@ -327,6 +331,5 @@ class ReaderViewModel(
)
}
}
}
}
}

View File

@@ -8,14 +8,19 @@ import androidx.core.graphics.Insets
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.fragment.app.commit
import com.google.android.material.appbar.AppBarLayout
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.databinding.ActivitySettingsSimpleBinding
import org.koitharu.kotatsu.main.ui.AppBarOwner
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.settings.*
class SimpleSettingsActivity : BaseActivity<ActivitySettingsSimpleBinding>() {
class SimpleSettingsActivity : BaseActivity<ActivitySettingsSimpleBinding>(), AppBarOwner {
override val appBar: AppBarLayout
get() = binding.appbar
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

View File

@@ -6,6 +6,7 @@ import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.databinding.LayoutPageInfoBinding
import org.koitharu.kotatsu.reader.domain.PageLoader
abstract class BasePageHolder<B : ViewBinding>(
@@ -16,6 +17,7 @@ abstract class BasePageHolder<B : ViewBinding>(
) : RecyclerView.ViewHolder(binding.root), PageHolderDelegate.Callback {
protected val delegate = PageHolderDelegate(loader, settings, this, exceptionResolver)
protected val bindingInfo = LayoutPageInfoBinding.bind(binding.root)
val context: Context
get() = itemView.context

View File

@@ -1,10 +1,12 @@
package org.koitharu.kotatsu.reader.ui.pager.reversed
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.children
import kotlin.math.absoluteValue
import kotlinx.coroutines.async
import org.koin.android.ext.android.get
import org.koitharu.kotatsu.databinding.FragmentReaderStandardBinding
@@ -17,7 +19,6 @@ import org.koitharu.kotatsu.utils.ext.doOnPageChanged
import org.koitharu.kotatsu.utils.ext.recyclerView
import org.koitharu.kotatsu.utils.ext.resetTransformations
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
import kotlin.math.absoluteValue
class ReversedReaderFragment : BaseReader<FragmentReaderStandardBinding>() {
@@ -28,6 +29,7 @@ class ReversedReaderFragment : BaseReader<FragmentReaderStandardBinding>() {
container: ViewGroup?
) = FragmentReaderStandardBinding.inflate(inflater, container, false)
@SuppressLint("NotifyDataSetChanged")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
pagerAdapter = ReversedPagesAdapter(viewModel.pageLoader, get(), exceptionResolver)

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.reader.ui.pager.standard
import android.annotation.SuppressLint
import android.graphics.PointF
import android.net.Uri
import android.view.View
@@ -14,8 +15,7 @@ import org.koitharu.kotatsu.databinding.ItemPageBinding
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.ifZero
import org.koitharu.kotatsu.utils.ext.*
open class PageHolder(
binding: ItemPageBinding,
@@ -27,10 +27,12 @@ open class PageHolder(
init {
binding.ssiv.setOnImageEventListener(delegate)
binding.buttonRetry.setOnClickListener(this)
@Suppress("LeakingThis")
bindingInfo.buttonRetry.setOnClickListener(this)
binding.textViewNumber.isVisible = settings.isPagesNumbersEnabled
}
@SuppressLint("SetTextI18n")
override fun onBind(data: ReaderPage) {
delegate.onBind(data.toMangaPage())
binding.textViewNumber.text = (data.index + 1).toString()
@@ -42,17 +44,17 @@ open class PageHolder(
}
override fun onLoadingStarted() {
binding.layoutError.isVisible = false
binding.progressBar.isVisible = true
bindingInfo.layoutError.isVisible = false
bindingInfo.progressBar.showCompat()
binding.ssiv.recycle()
}
override fun onProgressChanged(progress: Int) {
if (progress in 0..100) {
binding.progressBar.isIndeterminate = false
binding.progressBar.setProgressCompat(progress, true)
bindingInfo.progressBar.isIndeterminate = false
bindingInfo.progressBar.setProgressCompat(progress, true)
} else {
binding.progressBar.isIndeterminate = true
bindingInfo.progressBar.isIndeterminate = true
}
}
@@ -97,7 +99,7 @@ open class PageHolder(
}
override fun onImageShown() {
binding.progressBar.isVisible = false
bindingInfo.progressBar.hideCompat()
}
override fun onClick(v: View) {
@@ -107,11 +109,11 @@ open class PageHolder(
}
override fun onError(e: Throwable) {
binding.textViewError.text = e.getDisplayMessage(context.resources)
binding.buttonRetry.setText(
bindingInfo.textViewError.text = e.getDisplayMessage(context.resources)
bindingInfo.buttonRetry.setText(
ExceptionResolver.getResolveStringId(e).ifZero { R.string.try_again }
)
binding.layoutError.isVisible = true
binding.progressBar.isVisible = false
bindingInfo.layoutError.isVisible = true
bindingInfo.progressBar.hideCompat()
}
}

View File

@@ -1,10 +1,12 @@
package org.koitharu.kotatsu.reader.ui.pager.standard
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.children
import kotlin.math.absoluteValue
import kotlinx.coroutines.async
import org.koin.android.ext.android.get
import org.koitharu.kotatsu.databinding.FragmentReaderStandardBinding
@@ -16,7 +18,6 @@ import org.koitharu.kotatsu.utils.ext.doOnPageChanged
import org.koitharu.kotatsu.utils.ext.recyclerView
import org.koitharu.kotatsu.utils.ext.resetTransformations
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
import kotlin.math.absoluteValue
class PagerReaderFragment : BaseReader<FragmentReaderStandardBinding>() {
@@ -27,6 +28,7 @@ class PagerReaderFragment : BaseReader<FragmentReaderStandardBinding>() {
container: ViewGroup?
) = FragmentReaderStandardBinding.inflate(inflater, container, false)
@SuppressLint("NotifyDataSetChanged")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
pagesAdapter = PagesAdapter(viewModel.pageLoader, get(), exceptionResolver)

View File

@@ -13,8 +13,7 @@ import org.koitharu.kotatsu.databinding.ItemPageWebtoonBinding
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.ifZero
import org.koitharu.kotatsu.utils.ext.*
class WebtoonHolder(
@@ -29,7 +28,7 @@ class WebtoonHolder(
init {
binding.ssiv.setOnImageEventListener(delegate)
binding.buttonRetry.setOnClickListener(this)
bindingInfo.buttonRetry.setOnClickListener(this)
}
override fun onBind(data: ReaderPage) {
@@ -42,17 +41,17 @@ class WebtoonHolder(
}
override fun onLoadingStarted() {
binding.layoutError.isVisible = false
binding.progressBar.isVisible = true
bindingInfo.layoutError.isVisible = false
bindingInfo.progressBar.showCompat()
binding.ssiv.recycle()
}
override fun onProgressChanged(progress: Int) {
if (progress in 0..100) {
binding.progressBar.isIndeterminate = false
binding.progressBar.setProgressCompat(progress, true)
bindingInfo.progressBar.isIndeterminate = false
bindingInfo.progressBar.setProgressCompat(progress, true)
} else {
binding.progressBar.isIndeterminate = true
bindingInfo.progressBar.isIndeterminate = true
}
}
@@ -77,7 +76,7 @@ class WebtoonHolder(
}
override fun onImageShown() {
binding.progressBar.isVisible = false
bindingInfo.progressBar.hideCompat()
}
override fun onClick(v: View) {
@@ -87,12 +86,12 @@ class WebtoonHolder(
}
override fun onError(e: Throwable) {
binding.textViewError.text = e.getDisplayMessage(context.resources)
binding.buttonRetry.setText(
bindingInfo.textViewError.text = e.getDisplayMessage(context.resources)
bindingInfo.buttonRetry.setText(
ExceptionResolver.getResolveStringId(e).ifZero { R.string.try_again }
)
binding.layoutError.isVisible = true
binding.progressBar.isVisible = false
bindingInfo.layoutError.isVisible = true
bindingInfo.progressBar.hideCompat()
}
fun getScrollY() = binding.ssiv.getScroll()

View File

@@ -66,6 +66,7 @@ fun pageThumbnailAD(
onViewRecycled {
job?.cancel()
job = null
binding.imageViewThumb.setImageDrawable(null)
}
}

View File

@@ -15,7 +15,8 @@ import org.koitharu.kotatsu.main.ui.AppBarOwner
import org.koitharu.kotatsu.search.ui.suggestion.adapter.SearchSuggestionAdapter
import org.koitharu.kotatsu.utils.ext.measureHeight
class SearchSuggestionFragment : BaseFragment<FragmentSearchSuggestionBinding>(),
class SearchSuggestionFragment :
BaseFragment<FragmentSearchSuggestionBinding>(),
SearchSuggestionItemCallback.SuggestionItemListener {
private val viewModel by sharedViewModel<SearchSuggestionViewModel>()
@@ -44,8 +45,8 @@ class SearchSuggestionFragment : BaseFragment<FragmentSearchSuggestionBinding>()
val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top
binding.root.updatePadding(
top = headerHeight,
left = insets.left,
right = insets.right,
// left = insets.left,
// right = insets.right,
bottom = insets.bottom,
)
}

View File

@@ -37,7 +37,7 @@ fun searchSuggestionMangaListAD(
right = recyclerView.paddingRight - spacing,
)
recyclerView.addItemDecoration(SpacingItemDecoration(spacing))
val scrollResetCallback = RecyclerViewScrollCallback(recyclerView, 0)
val scrollResetCallback = RecyclerViewScrollCallback(recyclerView, 0, 0)
bind {
adapter.setItems(item.items, scrollResetCallback)

View File

@@ -17,15 +17,14 @@ class SearchBehavior(context: Context?, attrs: AttributeSet?) :
child: SearchToolbar,
dependency: View,
): Boolean {
return if (dependency is AppBarLayout) {
true
} else
if (dependency is LinearLayout || dependency is BottomNavigationView) {
return when (dependency) {
is AppBarLayout -> true
is LinearLayout, is BottomNavigationView -> {
dependency.z = child.z + 1
true
} else {
super.layoutDependsOn(parent, child, dependency)
}
else -> super.layoutDependsOn(parent, child, dependency)
}
}
override fun onDependentViewChanged(

View File

@@ -11,6 +11,7 @@ import androidx.appcompat.widget.AppCompatEditText
import androidx.core.content.ContextCompat
import com.google.android.material.R
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
import org.koitharu.kotatsu.utils.ext.drawableStart
private const val DRAWABLE_END = 2
@@ -57,7 +58,7 @@ class SearchEditText @JvmOverloads constructor(
) {
super.onTextChanged(text, start, lengthBefore, lengthAfter)
setCompoundDrawablesRelativeWithIntrinsicBounds(
null,
drawableStart,
null,
if (text.isNullOrEmpty()) null else clearIcon,
null,
@@ -86,4 +87,4 @@ class SearchEditText @JvmOverloads constructor(
super.clearFocus()
text?.clear()
}
}
}

View File

@@ -10,17 +10,24 @@ import androidx.fragment.app.FragmentTransaction
import androidx.fragment.app.commit
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import com.google.android.material.appbar.AppBarLayout
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.databinding.ActivitySettingsBinding
import org.koitharu.kotatsu.main.ui.AppBarOwner
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.ext.isScrolledToTop
class SettingsActivity : BaseActivity<ActivitySettingsBinding>(),
class SettingsActivity :
BaseActivity<ActivitySettingsBinding>(),
PreferenceFragmentCompat.OnPreferenceStartFragmentCallback,
AppBarOwner,
FragmentManager.OnBackStackChangedListener {
override val appBar: AppBarLayout
get() = binding.appbar
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivitySettingsBinding.inflate(layoutInflater))
@@ -35,7 +42,7 @@ class SettingsActivity : BaseActivity<ActivitySettingsBinding>(),
override fun onTitleChanged(title: CharSequence?, color: Int) {
super.onTitleChanged(title, color)
binding.collapsingToolbarLayout.title = title
binding.collapsingToolbarLayout?.title = title
}
override fun onStart() {

View File

@@ -14,7 +14,6 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.AlertDialogFragment
import org.koitharu.kotatsu.databinding.DialogProgressBinding
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.setIndeterminateCompat
import org.koitharu.kotatsu.utils.progress.Progress
import java.io.File
import java.io.FileOutputStream
@@ -65,7 +64,7 @@ class BackupDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
private fun onProgressChanged(progress: Progress?) {
with(binding.progressBar) {
setIndeterminateCompat(progress == null)
isIndeterminate = progress == null
isVisible = true
if (progress != null) {
this.max = progress.total

View File

@@ -11,7 +11,7 @@ import java.io.File
class BackupViewModel(
private val repository: BackupRepository,
private val context: Context
context: Context
) : BaseViewModel() {
val progress = MutableLiveData<Progress?>(null)

View File

@@ -3,6 +3,8 @@ package org.koitharu.kotatsu.settings.backup
import android.content.Context
import android.net.Uri
import androidx.lifecycle.MutableLiveData
import java.io.File
import java.io.FileNotFoundException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.runInterruptible
@@ -14,13 +16,11 @@ import org.koitharu.kotatsu.core.backup.CompositeResult
import org.koitharu.kotatsu.core.backup.RestoreRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.progress.Progress
import java.io.File
import java.io.FileNotFoundException
class RestoreViewModel(
uri: Uri?,
private val repository: RestoreRepository,
private val context: Context
context: Context
) : BaseViewModel() {
val progress = MutableLiveData<Progress?>(null)
@@ -35,8 +35,7 @@ class RestoreViewModel(
val backup = runInterruptible(Dispatchers.IO) {
val tempFile = File.createTempFile("backup_", ".tmp")
(contentResolver.openInputStream(uri)
?: throw FileNotFoundException()).use { input ->
(contentResolver.openInputStream(uri) ?: throw FileNotFoundException()).use { input ->
tempFile.outputStream().use { output ->
input.copyTo(output)
}

View File

@@ -15,6 +15,7 @@ import org.koitharu.kotatsu.databinding.DialogOnboardBinding
import org.koitharu.kotatsu.settings.onboard.adapter.SourceLocalesAdapter
import org.koitharu.kotatsu.settings.onboard.model.SourceLocale
import org.koitharu.kotatsu.utils.ext.observeNotNull
import org.koitharu.kotatsu.utils.ext.showAllowStateLoss
import org.koitharu.kotatsu.utils.ext.withArgs
class OnboardDialogFragment : AlertDialogFragment<DialogOnboardBinding>(),
@@ -77,7 +78,7 @@ class OnboardDialogFragment : AlertDialogFragment<DialogOnboardBinding>(),
fun showWelcome(fm: FragmentManager) {
OnboardDialogFragment().withArgs(1) {
putBoolean(ARG_WELCOME, true)
}.show(fm, TAG)
}.showAllowStateLoss(fm, TAG)
}
}
}

View File

@@ -13,6 +13,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.databinding.FragmentSettingsSourcesBinding
import org.koitharu.kotatsu.main.ui.AppBarOwner
import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigAdapter
import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigListener
@@ -108,7 +109,10 @@ class SourcesSettingsFragment : BaseFragment<FragmentSettingsSourcesBinding>(),
return true
}
override fun onMenuItemActionExpand(item: MenuItem?): Boolean = true
override fun onMenuItemActionExpand(item: MenuItem?): Boolean {
(activity as? AppBarOwner)?.appBar?.setExpanded(false, true)
return true
}
override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
(item.actionView as SearchView).setQuery("", false)

View File

@@ -65,7 +65,7 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) :
setProgress(workData.build())
val chapters = details?.chapters ?: continue
when {
track.knownChaptersCount == -1 -> { //first check
track.knownChaptersCount == -1 -> { // first check
repository.storeTrackResult(
mangaId = track.manga.id,
knownChaptersCount = chapters.size,
@@ -74,7 +74,7 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) :
newChapters = emptyList()
)
}
track.knownChaptersCount == 0 && track.lastChapterId == 0L -> { //manga was empty on last check
track.knownChaptersCount == 0 && track.lastChapterId == 0L -> { // manga was empty on last check
repository.storeTrackResult(
mangaId = track.manga.id,
knownChaptersCount = track.knownChaptersCount,
@@ -82,7 +82,7 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) :
previousTrackChapterId = track.lastNotifiedChapterId,
newChapters = chapters
)
showNotification(track.manga, chapters)
showNotification(details, chapters)
}
chapters.size == track.knownChaptersCount -> {
if (chapters.lastOrNull()?.id == track.lastChapterId) {
@@ -110,7 +110,7 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) :
newChapters = newChapters
)
showNotification(
track.manga,
details,
newChapters.takeLastWhile { x -> x.id != track.lastNotifiedChapterId }
)
}
@@ -224,6 +224,7 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) :
.setSilent(true)
.setProgress(0, 0, true)
.setSmallIcon(android.R.drawable.stat_notify_sync)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_DEFERRED)
.setOngoing(true)
.build()
@@ -300,4 +301,4 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) :
}
}
}
}
}

View File

@@ -1,87 +0,0 @@
package org.koitharu.kotatsu.utils
import android.app.DownloadManager
import android.app.DownloadManager.Request.*
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.Uri
import android.os.Build
import android.os.Environment
import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.util.toFileNameSafe
import java.io.File
import kotlin.coroutines.resume
class DownloadManagerHelper(
private val context: Context,
private val cookieJar: CookieJar,
) {
private val manager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
private val subDir = context.getString(R.string.app_name).toFileNameSafe()
fun downloadPage(page: MangaPage, fullUrl: String): Long {
val uri = fullUrl.toUri()
val cookies = cookieJar.loadForRequest(fullUrl.toHttpUrl())
val dest = subDir + File.separator + uri.lastPathSegment
val request = DownloadManager.Request(uri)
.addRequestHeader(CommonHeaders.REFERER, page.referer)
.addRequestHeader(CommonHeaders.COOKIE, cookieHeader(cookies))
.setAllowedOverMetered(true)
.setAllowedNetworkTypes(NETWORK_WIFI or NETWORK_MOBILE)
.setNotificationVisibility(VISIBILITY_VISIBLE)
.setDestinationInExternalPublicDir(Environment.DIRECTORY_PICTURES, dest)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
@Suppress("DEPRECATION")
request.allowScanningByMediaScanner()
}
return manager.enqueue(request)
}
suspend fun awaitDownload(id: Long): Uri {
getUriForDownloadedFile(id)?.let { return it } // fast path
suspendCancellableCoroutine<Unit> { cont ->
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
if (
intent?.action == DownloadManager.ACTION_DOWNLOAD_COMPLETE &&
intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0L) == id
) {
context.unregisterReceiver(this)
cont.resume(Unit)
}
}
}
context.registerReceiver(
receiver,
IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
)
cont.invokeOnCancellation {
context.unregisterReceiver(receiver)
}
}
return checkNotNull(getUriForDownloadedFile(id))
}
private suspend fun getUriForDownloadedFile(id: Long) = withContext(Dispatchers.IO) {
manager.getUriForDownloadedFile(id)
}
private fun cookieHeader(cookies: List<Cookie>): String = buildString {
cookies.forEachIndexed { index, cookie ->
if (index > 0) append("; ")
append(cookie.name).append('=').append(cookie.value)
}
}
}

View File

@@ -0,0 +1,26 @@
package org.koitharu.kotatsu.utils
import android.content.Context
import android.net.Uri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import okio.IOException
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.reader.domain.PageLoader
class ExternalStorageHelper(context: Context) {
private val contentResolver = context.contentResolver
suspend fun savePage(page: MangaPage, destination: Uri) {
val pageLoader = PageLoader()
val pageFile = pageLoader.loadPage(page, force = false)
runInterruptible(Dispatchers.IO) {
contentResolver.openOutputStream(destination)?.use { output ->
pageFile.inputStream().use { input ->
input.copyTo(output)
}
} ?: throw IOException("Output stream is null")
}
}
}

View File

@@ -1,10 +1,15 @@
package org.koitharu.kotatsu.utils
import androidx.annotation.Px
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import java.lang.ref.WeakReference
class RecyclerViewScrollCallback(recyclerView: RecyclerView, private val position: Int) : Runnable {
class RecyclerViewScrollCallback(
recyclerView: RecyclerView,
private val position: Int,
@Px private val offset: Int,
) : Runnable {
private val recyclerViewRef = WeakReference(recyclerView)
@@ -13,7 +18,7 @@ class RecyclerViewScrollCallback(recyclerView: RecyclerView, private val positio
val lm = rv.layoutManager ?: return
rv.stopScroll()
if (lm is LinearLayoutManager) {
lm.scrollToPositionWithOffset(position, 0)
lm.scrollToPositionWithOffset(position, offset)
} else {
lm.scrollToPosition(position)
}

View File

@@ -5,8 +5,8 @@ import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkRequest
import android.net.Uri
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine
val Context.connectivityManager: ConnectivityManager
get() = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager

View File

@@ -1,9 +1,11 @@
package org.koitharu.kotatsu.utils.ext
import android.os.SystemClock
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.flow.transformLatest
fun <T> Flow<T>.onFirst(action: suspend (T) -> Unit): Flow<T> {
var isFirstCall = true
@@ -17,4 +19,19 @@ fun <T> Flow<T>.onFirst(action: suspend (T) -> Unit): Flow<T> {
inline fun <T, R> Flow<List<T>>.mapItems(crossinline transform: (T) -> R): Flow<List<R>> {
return map { list -> list.map(transform) }
}
fun <T> Flow<T>.throttle(timeoutMillis: (T) -> Long): Flow<T> {
var lastEmittedAt = 0L
return transformLatest { value ->
val delay = timeoutMillis(value)
val now = SystemClock.elapsedRealtime()
if (delay > 0L) {
if (lastEmittedAt + delay < now) {
delay(lastEmittedAt + delay - now)
}
}
emit(value)
lastEmittedAt = now
}
}

View File

@@ -2,7 +2,9 @@ package org.koitharu.kotatsu.utils.ext
import android.os.Bundle
import android.os.Parcelable
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.coroutineScope
import java.io.Serializable
@@ -34,4 +36,10 @@ inline fun <reified T : Serializable> Fragment.serializableArgument(name: String
fun Fragment.stringArgument(name: String) = lazy(LazyThreadSafetyMode.NONE) {
arguments?.getString(name)
}
fun DialogFragment.showAllowStateLoss(manager: FragmentManager, tag: String?) {
if (!manager.isStateSaved) {
show(manager, tag)
}
}

View File

@@ -0,0 +1,25 @@
package org.koitharu.kotatsu.utils.ext
import android.os.Build
import android.widget.ProgressBar
import androidx.core.view.isVisible
import androidx.core.widget.ContentLoadingProgressBar
import com.google.android.material.progressindicator.BaseProgressIndicator
fun ProgressBar.setProgressCompat(progress: Int, animate: Boolean) = when {
this is BaseProgressIndicator<*> -> setProgressCompat(progress, animate)
Build.VERSION.SDK_INT >= Build.VERSION_CODES.N -> setProgress(progress, animate)
else -> setProgress(progress)
}
fun ProgressBar.showCompat() = when (this) {
is BaseProgressIndicator<*> -> show()
is ContentLoadingProgressBar -> show()
else -> isVisible = true
}
fun ProgressBar.hideCompat() = when (this) {
is BaseProgressIndicator<*> -> hide()
is ContentLoadingProgressBar -> hide()
else -> isVisible = false
}

View File

@@ -14,7 +14,6 @@ import androidx.core.view.children
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.progressindicator.BaseProgressIndicator
import com.google.android.material.slider.Slider
import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewBindingViewHolder
import kotlin.math.roundToInt
@@ -138,19 +137,6 @@ inline fun <reified T> RecyclerView.ViewHolder.getItem(): T? {
return ((this as? AdapterDelegateViewBindingViewHolder<*, *>)?.item as? T)
}
@Deprecated("Useless")
fun BaseProgressIndicator<*>.setIndeterminateCompat(indeterminate: Boolean) {
if (isIndeterminate != indeterminate) {
if (indeterminate && visibility == View.VISIBLE) {
visibility = View.INVISIBLE
isIndeterminate = indeterminate
visibility = View.VISIBLE
} else {
isIndeterminate = indeterminate
}
}
}
fun Slider.setValueRounded(newValue: Float) {
val step = stepSize
value = (newValue / step).roundToInt() * step

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:alpha="0.3" android:color="?colorPrimary" android:state_checked="true"/>
<item android:color="@android:color/transparent"/>
</selector>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:alpha="0.2" android:color="?colorPrimary" />
</selector>

View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:alpha="0.3" android:color="?attr/colorPrimary" android:state_checked="true"/>
<!-- https://stackoverflow.com/questions/54685474/theme-attributes-in-color-selector-for-api-22 -->
<item android:alpha="0.3" android:color="@color/kotatsu_primary" android:state_checked="true"/>
<item android:color="@android:color/transparent"/>
</selector>

View File

@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:alpha="0.2" android:color="?attr/colorPrimary" />
<!-- https://stackoverflow.com/questions/54685474/theme-attributes-in-color-selector-for-api-22 -->
<item android:alpha="0.2" android:color="@color/kotatsu_primary" />
</selector>

View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="32dp" />
<solid android:color="?attr/colorTertiary" />

View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="5dp" />
<solid android:color="?colorAccent" />

View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="5dp" />
<solid android:color="?attr/colorTertiary" />

View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="5dp" />
<stroke

View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="16dp" />
<solid android:color="@color/dim" />

View File

@@ -7,5 +7,5 @@
android:viewportHeight="24.0">
<path
android:fillColor="@android:color/white"
android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
android:pathData="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
</vector>

View File

@@ -8,5 +8,5 @@
android:viewportHeight="24">
<path
android:fillColor="#000"
android:pathData="M12,2L1,21H23M12,6L19.53,19H4.47M11,10V14H13V10M11,16V18H13V16" />
</vector>
android:pathData="M12 2L1 21h22M12 6l7.53 13H4.47M11 10v4h2v-4m-2 6v2h2v-2" />
</vector>

View File

@@ -1,11 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z"/>
</vector>

View File

@@ -1,10 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M13.09 20C13.21 20.72 13.46 21.39 13.81 22H6C4.89 22 4 21.11 4 20V4C4 2.9 4.89 2 6 2H18C19.11 2 20 2.9 20 4V13.09C19.67 13.04 19.34 13 19 13C18.66 13 18.33 13.04 18 13.09V4H13V12L10.5 9.75L8 12V4H6V20H13.09M22.54 16.88L21.12 15.47L19 17.59L16.88 15.47L15.47 16.88L17.59 19L15.47 21.12L16.88 22.54L19 20.41L21.12 22.54L22.54 21.12L20.41 19L22.54 16.88Z"/>
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M13.09 20c0.12 0.72 0.37 1.39 0.72 2H6c-1.11 0-2-0.89-2-2V4c0-1.1 0.89-2 2-2h12c1.11 0 2 0.9 2 2v9.09C19.67 13.04 19.34 13 19 13c-0.34 0-0.67 0.04-1 0.09V4h-5v8l-2.5-2.25L8 12V4H6v16h7.09m9.45-3.12l-1.42-1.41L19 17.59l-2.12-2.12-1.41 1.41L17.59 19l-2.12 2.12 1.41 1.42L19 20.41l2.12 2.13 1.42-1.42L20.41 19l2.13-2.12z" />
</vector>

View File

@@ -1,10 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M19 1L14 6V17L19 12.5V1M21 5V18.5C19.9 18.15 18.7 18 17.5 18C15.8 18 13.35 18.65 12 19.5V6C10.55 4.9 8.45 4.5 6.5 4.5C4.55 4.5 2.45 4.9 1 6V20.65C1 20.9 1.25 21.15 1.5 21.15C1.6 21.15 1.65 21.1 1.75 21.1C3.1 20.45 5.05 20 6.5 20C8.45 20 10.55 20.4 12 21.5C13.35 20.65 15.8 20 17.5 20C19.15 20 20.85 20.3 22.25 21.05C22.35 21.1 22.4 21.1 22.5 21.1C22.75 21.1 23 20.85 23 20.6V6C22.4 5.55 21.75 5.25 21 5M10 18.41C8.75 18.09 7.5 18 6.5 18C5.44 18 4.18 18.19 3 18.5V7.13C3.91 6.73 5.14 6.5 6.5 6.5C7.86 6.5 9.09 6.73 10 7.13V18.41Z"/>
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M19 1l-5 5v11l5-4.5V1m2 4v13.5c-1.1-0.35-2.3-0.5-3.5-0.5-1.7 0-4.15 0.65-5.5 1.5V6c-1.45-1.1-3.55-1.5-5.5-1.5C4.55 4.5 2.45 4.9 1 6v14.65c0 0.25 0.25 0.5 0.5 0.5 0.1 0 0.15-0.05 0.25-0.05C3.1 20.45 5.05 20 6.5 20c1.95 0 4.05 0.4 5.5 1.5 1.35-0.85 3.8-1.5 5.5-1.5 1.65 0 3.35 0.3 4.75 1.05 0.1 0.05 0.15 0.05 0.25 0.05 0.25 0 0.5-0.25 0.5-0.5V6c-0.6-0.45-1.25-0.75-2-1M10 18.41C8.75 18.09 7.5 18 6.5 18c-1.06 0-2.32 0.19-3.5 0.5V7.13C3.91 6.73 5.14 6.5 6.5 6.5c1.36 0 2.59 0.23 3.5 0.63v11.28z" />
</vector>

View File

@@ -1,10 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M15.5,12C18,12 20,14 20,16.5C20,17.38 19.75,18.21 19.31,18.9L22.39,22L21,23.39L17.88,20.32C17.19,20.75 16.37,21 15.5,21C13,21 11,19 11,16.5C11,14 13,12 15.5,12M15.5,14A2.5,2.5 0 0,0 13,16.5A2.5,2.5 0 0,0 15.5,19A2.5,2.5 0 0,0 18,16.5A2.5,2.5 0 0,0 15.5,14M13,4V12L10.5,9.75L8,12V4H6V20H10C10.54,20.81 11.23,21.5 12.03,22H6A2,2 0 0,1 4,20V4A2,2 0 0,1 6,2H18A2,2 0 0,1 20,4V11.81C19.42,11.26 18.75,10.81 18,10.5V4H13Z"/>
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M15.5 12c2.5 0 4.5 2 4.5 4.5 0 0.88-0.25 1.71-0.69 2.4l3.08 3.1L21 23.39l-3.12-3.07C17.19 20.75 16.37 21 15.5 21 13 21 11 19 11 16.5s2-4.5 4.5-4.5m0 2a2.5 2.5 0 0 0-2.5 2.5 2.5 2.5 0 0 0 2.5 2.5 2.5 2.5 0 0 0 2.5-2.5 2.5 2.5 0 0 0-2.5-2.5M13 4v8l-2.5-2.25L8 12V4H6v16h4c0.54 0.81 1.23 1.5 2.03 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v7.81c-0.58-0.55-1.25-1-2-1.31V4h-5z" />
</vector>

View File

@@ -7,5 +7,5 @@
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41L9,16.17z" />
android:pathData="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z" />
</vector>

View File

@@ -2,10 +2,10 @@
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?colorControlNormal"
android:tint="?colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M5,13h14v-2L5,11v2zM3,17h14v-2L3,15v2zM7,7v2h14L21,7L7,7z" />
android:pathData="M5 13h14v-2H5v2zm-2 4h14v-2H3v2zM7 7v2h14V7H7z" />
</vector>

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z"/>
</vector>

View File

@@ -6,11 +6,11 @@
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M11.9653,11.9653m-8.6321,0a8.6321,8.6321 0,1 1,17.2642 0a8.6321,8.6321 0,1 1,-17.2642 0"
android:pathData="M3.333 11.965a8.632 8.632 0 1 1 17.264 0 8.632 8.632 0 1 1-17.264 0"
android:strokeWidth="2"
android:strokeColor="@android:color/white" />
<path
android:fillColor="@android:color/white"
android:pathData="m6.8889,10.7775l2.4533,0a2.8091,2.8707 0,1 1,0 2.3923l-2.4533,0a5.1501,5.263 0,1 0,0 -2.3923z"
android:pathData="M6.889 10.777h2.453a2.81 2.87 0 1 1 0 2.393H6.89a5.15 5.263 0 1 0 0-2.392z"
android:strokeWidth="1" />
</vector>

View File

@@ -8,5 +8,5 @@
android:viewportHeight="24">
<path
android:fillColor="#000"
android:pathData="M12 2C17.5 2 22 6.5 22 12S17.5 22 12 22 2 17.5 2 12 6.5 2 12 2M12 4C10.1 4 8.4 4.6 7.1 5.7L18.3 16.9C19.3 15.5 20 13.8 20 12C20 7.6 16.4 4 12 4M16.9 18.3L5.7 7.1C4.6 8.4 4 10.1 4 12C4 16.4 7.6 20 12 20C13.9 20 15.6 19.4 16.9 18.3Z" />
android:pathData="M12 2c5.5 0 10 4.5 10 10s-4.5 10-10 10S2 17.5 2 12 6.5 2 12 2m0 2c-1.9 0-3.6 0.6-4.9 1.7l11.2 11.2c1-1.4 1.7-3.1 1.7-4.9 0-4.4-3.6-8-8-8m4.9 14.3L5.7 7.1C4.6 8.4 4 10.1 4 12c0 4.4 3.6 8 8 8 1.9 0 3.6-0.6 4.9-1.7z" />
</vector>

View File

@@ -1,11 +0,0 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?android:textColorPrimary"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@android:color/white"
android:pathData="M7,10l5,5 5,-5z" />
</vector>

View File

@@ -7,5 +7,5 @@
android:viewportHeight="24.0">
<path
android:fillColor="@android:color/white"
android:pathData="M11,15h2v2h-2zM11,7h2v6h-2zM11.99,2C6.47,2 2,6.48 2,12s4.47,10 9.99,10C17.52,22 22,17.52 22,12S17.52,2 11.99,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8z" />
android:pathData="M11 15h2v2h-2zm0-8h2v6h-2zm0.99-5C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z" />
</vector>

View File

@@ -1,11 +0,0 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@android:color/white"
android:pathData="M16.5,3c-1.74,0 -3.41,0.81 -4.5,2.09C10.91,3.81 9.24,3 7.5,3 4.42,3 2,5.42 2,8.5c0,3.78 3.4,6.86 8.55,11.54L12,21.35l1.45,-1.32C18.6,15.36 22,12.28 22,8.5 22,5.42 19.58,3 16.5,3zM12.1,18.55l-0.1,0.1 -0.1,-0.1C7.14,14.24 4,11.39 4,8.5 4,6.5 5.5,5 7.5,5c1.54,0 3.04,0.99 3.57,2.36h1.87C13.46,5.99 14.96,5 16.5,5c2,0 3.5,1.5 3.5,3.5 0,2.89 -3.14,5.74 -7.9,10.05z" />
</vector>

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