Compare commits

..

25 Commits
v3.0 ... v3.1

Author SHA1 Message Date
Koitharu
2e17efe82b Update parsers 2022-04-11 18:43:52 +03:00
Koitharu
5bed854b9c Refactor entity mapping 2022-04-10 11:00:05 +03:00
Koitharu
7262b403f0 Hide reading fab if history is empty 2022-04-10 10:25:11 +03:00
Koitharu
a6fcbefc7b Update strings 2022-04-09 18:47:25 +03:00
Koitharu
7f9ea0efa0 Merge branch 'feature/multiselect' into devel 2022-04-09 18:39:25 +03:00
Koitharu
934861322e Migrate to ExtendedFloatingActionButton 2022-04-09 18:35:07 +03:00
Koitharu
e008fbab9b Merge branch 'devel' into feature/multiselect 2022-04-09 08:29:57 +03:00
Koitharu
2cd9ea19fd Update dependencies 2022-04-09 08:28:01 +03:00
Koitharu
699a249620 Merge branch 'documentation-update' of https://github.com/grrrrr/Kotatsu into devel 2022-04-09 07:35:16 +03:00
Koitharu
6c87d5b0bc Add check to avoid TransactionTooLargeException 2022-04-08 18:15:04 +03:00
Koitharu
c92bdae842 Add tags blacklist option for suggestions 2022-04-08 14:56:45 +03:00
Koitharu
6ca9608a80 Remove CurlLoggingInterceptor 2022-04-07 17:23:59 +03:00
Koitharu
8f9c0cbff1 Fix tags suggestion 2022-04-07 17:20:02 +03:00
Koitharu
cc6b114e4d Improve suggestions worker 2022-04-07 17:04:11 +03:00
grrrrr
3d5c2123d4 Update full_description.txt
- Remove HTML code so displaying on sites such as f-droid does not create a lot of wasted space
2022-04-06 19:13:43 +00:00
grrrrr
36b4e16b7c Update full_description.txt
- Remote HTML code so displaying on sites such as f-droid does not create a lot of wasted space
- add additional features taken from updated README.md
2022-04-06 19:12:15 +00:00
grrrrr
3ebd074e93 Update README.md
change feature "localized in" to "available in
2022-04-06 19:10:23 +00:00
grrrrr
e9b2b545a4 Update README.md
- Add additional features (password protection and localization) to list.
- Add details on how to contribute to translation
2022-04-06 19:05:33 +00:00
Koitharu
cca6d5fa04 Migrate to expedited jobs 2022-04-06 18:38:37 +03:00
Koitharu
36a7a3ebbc Fix DownloadService foreground notification #50 2022-04-06 17:24:10 +03:00
Koitharu
48ec9a1ea9 Merge branch 'feature/settings' into devel 2022-04-06 17:23:08 +03:00
Koitharu
76a9a0d1ab ActionMode selection in manga lists 2022-04-06 17:21:09 +03:00
Koitharu
f2175b40c0 Improve android AutoBackup support 2022-04-05 07:40:00 +03:00
Koitharu
85b992ca32 Remove SimpleSettingsActivity 2022-04-04 10:02:20 +03:00
Koitharu
41fb351fe0 Use master-detals pattern for settings 2022-04-04 09:41:57 +03:00
164 changed files with 2336 additions and 1593 deletions

View File

@@ -25,6 +25,8 @@ Download APK from Github Releases:
* Tablet-optimized material design UI
* Standard and Webtoon-optimized reader
* Notifications about new chapters with updates feed
* Available in multiple languages
* Password protect access to the app
### Screenshots
@@ -35,6 +37,14 @@ Download APK from Github Releases:
| ![](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) |
|-----------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------|
### Localization
<a href="https://hosted.weblate.org/engage/kotatsu/">
<img src="https://hosted.weblate.org/widgets/kotatsu/-/287x66-white.png" alt="Translation status" />
</a>
Kotatsu is localized in a number of different languages, if you would like to help improve these or add new languages, please head over to the Weblate <a href="https://hosted.weblate.org/engage/kotatsu/">project page</a>
### License
[![GNU GPLv3 Image](https://www.gnu.org/graphics/gplv3-127x51.png)](http://www.gnu.org/licenses/gpl-3.0.en.html)

View File

@@ -14,8 +14,8 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdkVersion 21
targetSdkVersion 32
versionCode 400
versionName '3.0'
versionCode 401
versionName '3.1'
generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -65,12 +65,12 @@ android {
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
implementation('com.github.nv95:kotatsu-parsers:3ea7e92e64') {
implementation('com.github.nv95:kotatsu-parsers:8e23a7fcd4') {
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'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.1'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.1'
implementation 'androidx.core:core-ktx:1.7.0'
implementation 'androidx.activity:activity-ktx:1.4.0'
@@ -100,7 +100,7 @@ dependencies {
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
implementation 'io.insert-koin:koin-android:3.1.5'
implementation 'io.insert-koin:koin-android:3.1.6'
implementation 'io.coil-kt:coil-base:1.4.0'
implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
implementation 'com.github.solkin:disk-lru-cache:1.4'
@@ -108,7 +108,7 @@ dependencies {
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.8.1'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.0'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.1'
testImplementation 'io.insert-koin:koin-test-junit4:3.1.5'
androidTestImplementation 'androidx.test:runner:1.4.0'

View File

@@ -8,23 +8,23 @@
<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"
android:maxSdkVersion="28"
tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
android:name="org.koitharu.kotatsu.KotatsuApp"
android:allowBackup="true"
android:fullBackupContent="@xml/backup_descriptor"
android:backupAgent="org.koitharu.kotatsu.settings.backup.AppBackupAgent"
android:dataExtractionRules="@xml/backup_rules"
android:fullBackupContent="@xml/backup_content"
android:fullBackupOnly="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Kotatsu"
android:networkSecurityConfig="@xml/network_security_config"
tools:ignore="UnusedAttribute">
<activity
android:name="org.koitharu.kotatsu.main.ui.MainActivity"
android:exported="true">
@@ -58,15 +58,6 @@
<activity
android:name="org.koitharu.kotatsu.settings.SettingsActivity"
android:label="@string/settings" />
<activity
android:name="org.koitharu.kotatsu.reader.ui.SimpleSettingsActivity"
android:exported="true"
android:label="@string/settings">
<intent-filter>
<action android:name="android.intent.action.MANAGE_NETWORK_USAGE" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity
android:name="org.koitharu.kotatsu.browser.BrowserActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
@@ -104,6 +95,7 @@
android:windowSoftInputMode="adjustResize" />
<activity
android:name="org.koitharu.kotatsu.download.ui.DownloadsActivity"
android:launchMode="singleTop"
android:label="@string/downloads" />
<activity android:name="org.koitharu.kotatsu.image.ui.ImageActivity"/>

View File

@@ -2,22 +2,19 @@ package org.koitharu.kotatsu.base.domain
import androidx.room.withTransaction
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.core.db.entity.*
import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.utils.ext.mapToSet
class MangaDataRepository(private val db: MangaDatabase) {
suspend fun savePreferences(manga: Manga, mode: ReaderMode) {
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag)
val tags = manga.tags.toEntities()
db.withTransaction {
db.tagsDao.upsert(tags)
db.mangaDao.upsert(MangaEntity.from(manga), tags)
db.mangaDao.upsert(manga.toEntity(), tags)
db.preferencesDao.upsert(
MangaPrefsEntity(
mangaId = manga.id,
@@ -37,21 +34,19 @@ class MangaDataRepository(private val db: MangaDatabase) {
suspend fun resolveIntent(intent: MangaIntent): Manga? = when {
intent.manga != null -> intent.manga
intent.mangaId != 0L -> db.mangaDao.find(intent.mangaId)?.toManga()
intent.mangaId != 0L -> findMangaById(intent.mangaId)
else -> null // TODO resolve uri
}
suspend fun storeManga(manga: Manga) {
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag)
val tags = manga.tags.toEntities()
db.withTransaction {
db.tagsDao.upsert(tags)
db.mangaDao.upsert(MangaEntity.from(manga), tags)
db.mangaDao.upsert(manga.toEntity(), tags)
}
}
suspend fun findTags(source: MangaSource): Set<MangaTag> {
return db.tagsDao.findTags(source.name).mapToSet {
it.toMangaTag()
}
return db.tagsDao.findTags(source.name).toMangaTags()
}
}

View File

@@ -14,7 +14,7 @@ import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.utils.ext.medianOrNull
import org.koitharu.kotatsu.parsers.util.medianOrNull
import java.io.InputStream
import java.util.zip.ZipFile

View File

@@ -7,6 +7,7 @@ import android.view.KeyEvent
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.annotation.CallSuper
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.ActionBarContextView
@@ -20,6 +21,7 @@ import androidx.viewbinding.ViewBinding
import org.koin.android.ext.android.get
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.util.ActionModeDelegate
import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.prefs.AppSettings
@@ -36,6 +38,8 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(),
@Suppress("LeakingThis")
protected val insetsDelegate = WindowInsetsDelegate(this)
val actionModeDelegate = ActionModeDelegate()
override fun onCreate(savedInstanceState: Bundle?) {
val settings = get<AppSettings>()
when {
@@ -90,8 +94,10 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(),
return isNight && get<AppSettings>().isAmoledTheme
}
@CallSuper
override fun onSupportActionModeStarted(mode: ActionMode) {
super.onSupportActionModeStarted(mode)
actionModeDelegate.onSupportActionModeStarted(mode)
val insets = ViewCompat.getRootWindowInsets(binding.root)
?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: return
val view = findViewById<ActionBarContextView?>(androidx.appcompat.R.id.action_mode_bar)
@@ -100,6 +106,12 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(),
}
}
@CallSuper
override fun onSupportActionModeFinished(mode: ActionMode) {
super.onSupportActionModeFinished(mode)
actionModeDelegate.onSupportActionModeFinished(mode)
}
override fun onBackPressed() {
if ( // https://issuetracker.google.com/issues/139738913
Build.VERSION.SDK_INT == Build.VERSION_CODES.Q &&

View File

@@ -6,10 +6,12 @@ import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.viewbinding.ViewBinding
import org.koitharu.kotatsu.base.ui.util.ActionModeDelegate
import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
abstract class BaseFragment<B : ViewBinding> : Fragment(),
abstract class BaseFragment<B : ViewBinding> :
Fragment(),
WindowInsetsDelegate.WindowInsetsListener {
private var viewBinding: B? = null
@@ -23,6 +25,9 @@ abstract class BaseFragment<B : ViewBinding> : Fragment(),
@Suppress("LeakingThis")
protected val insetsDelegate = WindowInsetsDelegate(this)
protected val actionModeDelegate: ActionModeDelegate
get() = (requireActivity() as BaseActivity<*>).actionModeDelegate
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
@@ -47,4 +52,4 @@ abstract class BaseFragment<B : ViewBinding> : Fragment(),
protected fun bindingOrNull() = viewBinding
protected abstract fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): B
}
}

View File

@@ -6,14 +6,18 @@ import androidx.annotation.CallSuper
import androidx.annotation.StringRes
import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.preference.PreferenceFragmentCompat
import androidx.recyclerview.widget.RecyclerView
import org.koin.android.ext.android.inject
import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.settings.SettingsHeadersFragment
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) : PreferenceFragmentCompat(),
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
PreferenceFragmentCompat(),
WindowInsetsDelegate.WindowInsetsListener,
RecyclerViewOwner {
@@ -39,16 +43,20 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) : Pre
override fun onResume() {
super.onResume()
if (titleId != 0) {
activity?.setTitle(titleId)
setTitle(getString(titleId))
}
}
@CallSuper
override fun onWindowInsetsChanged(insets: Insets) {
listView.updatePadding(
left = insets.left,
right = insets.right,
bottom = insets.bottom
)
}
}
@Suppress("UsePropertyAccessSyntax")
protected fun setTitle(title: CharSequence) {
(parentFragment as? SettingsHeadersFragment)?.setTitle(title)
?: activity?.setTitle(title)
}
}

View File

@@ -0,0 +1,111 @@
package org.koitharu.kotatsu.base.ui.list.decor
import android.graphics.Canvas
import android.graphics.Rect
import android.graphics.RectF
import android.view.View
import androidx.core.view.children
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.NO_ID
abstract class AbstractSelectionItemDecoration : RecyclerView.ItemDecoration() {
private val bounds = Rect()
private val boundsF = RectF()
private val selection = HashSet<Long>()
protected var hasBackground: Boolean = true
protected var hasForeground: Boolean = false
protected var isIncludeDecorAndMargins: Boolean = true
val checkedItemsCount: Int
get() = selection.size
val checkedItemsIds: Set<Long>
get() = selection
fun toggleItemChecked(id: Long) {
if (!selection.remove(id)) {
selection.add(id)
}
}
fun setItemIsChecked(id: Long, isChecked: Boolean) {
if (isChecked) {
selection.add(id)
} else {
selection.remove(id)
}
}
fun checkAll(ids: Collection<Long>) {
selection.addAll(ids)
}
fun clearSelection() {
selection.clear()
}
override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
if (hasBackground) {
doDraw(canvas, parent, state, false)
} else {
super.onDraw(canvas, parent, state)
}
}
override fun onDrawOver(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
if (hasForeground) {
doDraw(canvas, parent, state, true)
} else {
super.onDrawOver(canvas, parent, state)
}
}
private fun doDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State, isOver: Boolean) {
val checkpoint = canvas.save()
if (parent.clipToPadding) {
canvas.clipRect(
parent.paddingLeft, parent.paddingTop, parent.width - parent.paddingRight,
parent.height - parent.paddingBottom
)
}
for (child in parent.children) {
val itemId = getItemId(parent, child)
if (itemId != NO_ID && itemId in selection) {
if (isIncludeDecorAndMargins) {
parent.getDecoratedBoundsWithMargins(child, bounds)
} else {
bounds.set(child.left, child.top, child.right, child.bottom)
}
boundsF.set(bounds)
boundsF.offset(child.translationX, child.translationY)
if (isOver) {
onDrawForeground(canvas, parent, child, boundsF, state)
} else {
onDrawBackground(canvas, parent, child, boundsF, state)
}
}
}
canvas.restoreToCount(checkpoint)
}
protected open fun getItemId(parent: RecyclerView, child: View) = parent.getChildItemId(child)
protected open fun onDrawBackground(
canvas: Canvas,
parent: RecyclerView,
child: View,
bounds: RectF,
state: RecyclerView.State,
) = Unit
protected open fun onDrawForeground(
canvas: Canvas,
parent: RecyclerView,
child: View,
bounds: RectF,
state: RecyclerView.State,
) = Unit
}

View File

@@ -0,0 +1,50 @@
package org.koitharu.kotatsu.base.ui.util
import androidx.appcompat.view.ActionMode
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
class ActionModeDelegate {
private var activeActionMode: ActionMode? = null
private var listeners: MutableList<ActionModeListener>? = null
val isActionModeStarted: Boolean
get() = activeActionMode != null
fun onSupportActionModeStarted(mode: ActionMode) {
activeActionMode = mode
listeners?.forEach { it.onActionModeStarted(mode) }
}
fun onSupportActionModeFinished(mode: ActionMode) {
activeActionMode = null
listeners?.forEach { it.onActionModeFinished(mode) }
}
fun addListener(listener: ActionModeListener) {
if (listeners == null) {
listeners = ArrayList()
}
checkNotNull(listeners).add(listener)
}
fun removeListener(listener: ActionModeListener) {
listeners?.remove(listener)
}
fun addListener(listener: ActionModeListener, owner: LifecycleOwner) {
addListener(listener)
owner.lifecycle.addObserver(ListenerLifecycleObserver(listener))
}
private inner class ListenerLifecycleObserver(
private val listener: ActionModeListener,
) : DefaultLifecycleObserver {
override fun onDestroy(owner: LifecycleOwner) {
super.onDestroy(owner)
removeListener(listener)
}
}
}

View File

@@ -0,0 +1,10 @@
package org.koitharu.kotatsu.base.ui.util
import androidx.appcompat.view.ActionMode
interface ActionModeListener {
fun onActionModeStarted(mode: ActionMode)
fun onActionModeFinished(mode: ActionMode)
}

View File

@@ -0,0 +1,48 @@
package org.koitharu.kotatsu.base.ui.util
import android.content.Context
import android.util.AttributeSet
import android.view.View
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.coordinatorlayout.widget.CoordinatorLayout.Behavior
import androidx.core.view.ViewCompat
import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
class ShrinkOnScrollBehavior : Behavior<ExtendedFloatingActionButton> {
@Suppress("unused") constructor() : super()
@Suppress("unused") constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
override fun onStartNestedScroll(
coordinatorLayout: CoordinatorLayout,
child: ExtendedFloatingActionButton,
directTargetChild: View,
target: View,
axes: Int,
type: Int
): Boolean {
return axes == ViewCompat.SCROLL_AXIS_VERTICAL
}
override fun onNestedScroll(
coordinatorLayout: CoordinatorLayout,
child: ExtendedFloatingActionButton,
target: View,
dxConsumed: Int,
dyConsumed: Int,
dxUnconsumed: Int,
dyUnconsumed: Int,
type: Int,
consumed: IntArray
) {
if (dyConsumed > 0) {
if (child.isExtended) {
child.shrink()
}
} else if (dyConsumed < 0) {
if (!child.isExtended) {
child.extend()
}
}
}
}

View File

@@ -1,28 +1,9 @@
package org.koitharu.kotatsu.core.db
import androidx.room.Room
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
import org.koitharu.kotatsu.core.db.migrations.*
val databaseModule
get() = module {
single {
Room.databaseBuilder(
androidContext(),
MangaDatabase::class.java,
"kotatsu-db"
).addMigrations(
Migration1To2(),
Migration2To3(),
Migration3To4(),
Migration4To5(),
Migration5To6(),
Migration6To7(),
Migration7To8(),
Migration8To9(),
).addCallback(
DatabasePrePopulateCallback(androidContext().resources)
).build()
}
single { MangaDatabase.create(androidContext()) }
}

View File

@@ -1,9 +1,12 @@
package org.koitharu.kotatsu.core.db
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import org.koitharu.kotatsu.core.db.dao.*
import org.koitharu.kotatsu.core.db.entity.*
import org.koitharu.kotatsu.core.db.migrations.*
import org.koitharu.kotatsu.favourites.data.FavouriteCategoriesDao
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
@@ -40,4 +43,24 @@ abstract class MangaDatabase : RoomDatabase() {
abstract val trackLogsDao: TrackLogsDao
abstract val suggestionDao: SuggestionDao
companion object {
fun create(context: Context): MangaDatabase = Room.databaseBuilder(
context,
MangaDatabase::class.java,
"kotatsu-db"
).addMigrations(
Migration1To2(),
Migration2To3(),
Migration3To4(),
Migration4To5(),
Migration5To6(),
Migration6To7(),
Migration7To8(),
Migration8To9(),
).addCallback(
DatabasePrePopulateCallback(context.resources)
).build()
}
}

View File

@@ -12,7 +12,7 @@ abstract class TagsDao {
@Query(
"""SELECT tags.* FROM tags
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
GROUP BY manga_tags.tag_id
GROUP BY tags.title
ORDER BY COUNT(manga_id) DESC
LIMIT :limit"""
)
@@ -22,7 +22,7 @@ abstract class TagsDao {
"""SELECT tags.* FROM tags
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
WHERE tags.source = :source
GROUP BY manga_tags.tag_id
GROUP BY tags.title
ORDER BY COUNT(manga_id) DESC
LIMIT :limit"""
)
@@ -32,7 +32,7 @@ abstract class TagsDao {
"""SELECT tags.* FROM tags
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
WHERE tags.source = :source AND title LIKE :query
GROUP BY manga_tags.tag_id
GROUP BY tags.title
ORDER BY COUNT(manga_id) DESC
LIMIT :limit"""
)
@@ -42,7 +42,7 @@ abstract class TagsDao {
"""SELECT tags.* FROM tags
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
WHERE title LIKE :query
GROUP BY manga_tags.tag_id
GROUP BY tags.title
ORDER BY COUNT(manga_id) DESC
LIMIT :limit"""
)

View File

@@ -0,0 +1,76 @@
package org.koitharu.kotatsu.core.db.entity
import java.util.*
import org.koitharu.kotatsu.core.model.TrackingLogItem
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.longHashCode
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase
// Entity to model
fun TagEntity.toMangaTag() = MangaTag(
key = this.key,
title = this.title.toTitleCase(),
source = MangaSource.valueOf(this.source),
)
fun Collection<TagEntity>.toMangaTags() = mapToSet(TagEntity::toMangaTag)
fun MangaEntity.toManga(tags: Set<MangaTag>) = Manga(
id = this.id,
title = this.title,
altTitle = this.altTitle,
state = this.state?.let { MangaState.valueOf(it) },
rating = this.rating,
isNsfw = this.isNsfw,
url = this.url,
publicUrl = this.publicUrl,
coverUrl = this.coverUrl,
largeCoverUrl = this.largeCoverUrl,
author = this.author,
source = MangaSource.valueOf(this.source),
tags = tags
)
fun MangaWithTags.toManga() = manga.toManga(tags.toMangaTags())
fun TrackLogWithManga.toTrackingLogItem() = TrackingLogItem(
id = trackLog.id,
chapters = trackLog.chapters.split('\n').filterNot { x -> x.isEmpty() },
manga = manga.toManga(tags.toMangaTags()),
createdAt = Date(trackLog.createdAt)
)
// Model to entity
fun Manga.toEntity() = MangaEntity(
id = id,
url = url,
publicUrl = publicUrl,
source = source.name,
largeCoverUrl = largeCoverUrl,
coverUrl = coverUrl,
altTitle = altTitle,
rating = rating,
isNsfw = isNsfw,
state = state?.name,
title = title,
author = author,
)
fun MangaTag.toEntity() = TagEntity(
title = title,
key = key,
source = source.name,
id = "${key}_${source.name}".longHashCode()
)
fun Collection<MangaTag>.toEntities() = map(MangaTag::toEntity)
// Other
@Suppress("FunctionName")
fun SortOrder(name: String, fallback: SortOrder): SortOrder = runCatching {
SortOrder.valueOf(name)
}.getOrDefault(fallback)

View File

@@ -3,10 +3,6 @@ package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
@Entity(tableName = "manga")
class MangaEntity(
@@ -16,46 +12,11 @@ class MangaEntity(
@ColumnInfo(name = "alt_title") val altTitle: String?,
@ColumnInfo(name = "url") val url: String,
@ColumnInfo(name = "public_url") val publicUrl: String,
@ColumnInfo(name = "rating") val rating: Float, //normalized value [0..1] or -1
@ColumnInfo(name = "rating") val rating: Float, // normalized value [0..1] or -1
@ColumnInfo(name = "nsfw") val isNsfw: Boolean,
@ColumnInfo(name = "cover_url") val coverUrl: String,
@ColumnInfo(name = "large_cover_url") val largeCoverUrl: String?,
@ColumnInfo(name = "state") val state: String?,
@ColumnInfo(name = "author") val author: String?,
@ColumnInfo(name = "source") val source: String
) {
fun toManga(tags: Set<MangaTag> = emptySet()) = Manga(
id = this.id,
title = this.title,
altTitle = this.altTitle,
state = this.state?.let { MangaState.valueOf(it) },
rating = this.rating,
isNsfw = this.isNsfw,
url = this.url,
publicUrl = this.publicUrl,
coverUrl = this.coverUrl,
largeCoverUrl = this.largeCoverUrl,
author = this.author,
source = MangaSource.valueOf(this.source),
tags = tags
)
companion object {
fun from(manga: Manga) = MangaEntity(
id = manga.id,
url = manga.url,
publicUrl = manga.publicUrl,
source = manga.source.name,
largeCoverUrl = manga.largeCoverUrl,
coverUrl = manga.coverUrl,
altTitle = manga.altTitle,
rating = manga.rating,
isNsfw = manga.isNsfw,
state = manga.state?.name,
title = manga.title,
author = manga.author
)
}
}
)

View File

@@ -6,13 +6,15 @@ import androidx.room.ForeignKey
import androidx.room.PrimaryKey
@Entity(
tableName = "preferences", foreignKeys = [
tableName = "preferences",
foreignKeys = [
ForeignKey(
entity = MangaEntity::class,
parentColumns = ["manga_id"],
childColumns = ["manga_id"],
onDelete = ForeignKey.CASCADE
)]
)
]
)
class MangaPrefsEntity(
@PrimaryKey(autoGenerate = false)

View File

@@ -5,7 +5,8 @@ import androidx.room.Entity
import androidx.room.ForeignKey
@Entity(
tableName = "manga_tags", primaryKeys = ["manga_id", "tag_id"], foreignKeys = [
tableName = "manga_tags", primaryKeys = ["manga_id", "tag_id"],
foreignKeys = [
ForeignKey(
entity = MangaEntity::class,
parentColumns = ["manga_id"],

View File

@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.core.db.entity
import androidx.room.Embedded
import androidx.room.Junction
import androidx.room.Relation
import org.koitharu.kotatsu.utils.ext.mapToSet
class MangaWithTags(
@Embedded val manga: MangaEntity,
@@ -12,10 +11,5 @@ class MangaWithTags(
entityColumn = "tag_id",
associateBy = Junction(MangaTagsEntity::class)
)
val tags: List<TagEntity>
) {
fun toManga() = manga.toManga(tags.mapToSet {
it.toMangaTag()
})
}
val tags: List<TagEntity>,
)

View File

@@ -3,10 +3,6 @@ package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.longHashCode
import org.koitharu.kotatsu.parsers.util.toTitleCase
@Entity(tableName = "tags")
class TagEntity(
@@ -15,21 +11,4 @@ class TagEntity(
@ColumnInfo(name = "title") val title: String,
@ColumnInfo(name = "key") val key: String,
@ColumnInfo(name = "source") val source: String
) {
fun toMangaTag() = MangaTag(
key = this.key,
title = this.title.toTitleCase(),
source = MangaSource.valueOf(this.source)
)
companion object {
fun fromMangaTag(tag: MangaTag) = TagEntity(
title = tag.title,
key = tag.key,
source = tag.source.name,
id = "${tag.key}_${tag.source.name}".longHashCode()
)
}
}
)

View File

@@ -6,7 +6,8 @@ import androidx.room.ForeignKey
import androidx.room.PrimaryKey
@Entity(
tableName = "tracks", foreignKeys = [
tableName = "tracks",
foreignKeys = [
ForeignKey(
entity = MangaEntity::class,
parentColumns = ["manga_id"],

View File

@@ -6,7 +6,8 @@ import androidx.room.ForeignKey
import androidx.room.PrimaryKey
@Entity(
tableName = "track_logs", foreignKeys = [
tableName = "track_logs",
foreignKeys = [
ForeignKey(
entity = MangaEntity::class,
parentColumns = ["manga_id"],
@@ -20,5 +21,5 @@ class TrackLogEntity(
@ColumnInfo(name = "id") val id: Long = 0L,
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
@ColumnInfo(name = "chapters") val chapters: String,
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis()
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(),
)

View File

@@ -3,9 +3,6 @@ package org.koitharu.kotatsu.core.db.entity
import androidx.room.Embedded
import androidx.room.Junction
import androidx.room.Relation
import org.koitharu.kotatsu.core.model.TrackingLogItem
import org.koitharu.kotatsu.utils.ext.mapToSet
import java.util.*
class TrackLogWithManga(
@Embedded val trackLog: TrackLogEntity,
@@ -19,13 +16,5 @@ class TrackLogWithManga(
entityColumn = "tag_id",
associateBy = Junction(MangaTagsEntity::class)
)
val tags: List<TagEntity>
) {
fun toTrackingLogItem() = TrackingLogItem(
id = trackLog.id,
chapters = trackLog.chapters.split('\n').filterNot { x -> x.isEmpty() },
manga = manga.toManga(tags.mapToSet { x -> x.toMangaTag() }),
createdAt = Date(trackLog.createdAt)
)
}
val tags: List<TagEntity>,
)

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core.model
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.mapToSet
fun Manga.withoutChapters() = if (chapters.isNullOrEmpty()) {
this
@@ -22,4 +23,6 @@ fun Manga.withoutChapters() = if (chapters.isNullOrEmpty()) {
chapters = null,
source = source,
)
}
}
fun Collection<Manga>.ids() = mapToSet { it.id }

View File

@@ -4,7 +4,7 @@ import android.os.Parcel
import androidx.core.os.ParcelCompat
import org.koitharu.kotatsu.parsers.model.*
fun Manga.writeToParcel(out: Parcel, flags: Int) {
fun Manga.writeToParcel(out: Parcel, flags: Int, withChapters: Boolean) {
out.writeLong(id)
out.writeString(title)
out.writeString(altTitle)
@@ -18,7 +18,11 @@ fun Manga.writeToParcel(out: Parcel, flags: Int) {
out.writeParcelable(ParcelableMangaTags(tags), flags)
out.writeSerializable(state)
out.writeString(author)
out.writeParcelable(chapters?.let(::ParcelableMangaChapters), flags)
if (withChapters) {
out.writeParcelable(chapters?.let(::ParcelableMangaChapters), flags)
} else {
out.writeString(null)
}
out.writeSerializable(source)
}

View File

@@ -2,24 +2,34 @@ 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
// Limits to avoid TransactionTooLargeException
private const val MAX_SAFE_SIZE = 1024 * 512 // Assume that 512 kb is safe parcel size
private const val MAX_SAFE_CHAPTERS_COUNT = 40 // this is 100% safe
class ParcelableManga(
val manga: Manga,
) : 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)
val chapters = manga.chapters
if (chapters == null || chapters.size <= MAX_SAFE_CHAPTERS_COUNT) {
// fast path
manga.writeToParcel(parcel, flags, withChapters = true)
return
}
val tempParcel = Parcel.obtain()
manga.writeToParcel(tempParcel, flags, withChapters = true)
val size = tempParcel.dataSize()
if (size < MAX_SAFE_SIZE) {
parcel.appendFrom(tempParcel, 0, size)
} else {
manga.writeToParcel(parcel, flags, withChapters = false)
}
tempParcel.recycle()
}
override fun describeContents(): Int {

View File

@@ -1,56 +0,0 @@
package org.koitharu.kotatsu.core.network
import android.util.Log
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import okio.Buffer
import java.io.IOException
import java.nio.charset.StandardCharsets
private const val TAG = "CURL"
class CurlLoggingInterceptor(
private val extraCurlOptions: String? = null,
) : Interceptor {
@Throws(IOException::class)
override fun intercept(chain: Interceptor.Chain): Response {
val request: Request = chain.request()
var compressed = false
val curlCmd = StringBuilder("curl")
if (extraCurlOptions != null) {
curlCmd.append(" ").append(extraCurlOptions)
}
curlCmd.append(" -X ").append(request.method)
val headers = request.headers
var i = 0
val count = headers.size
while (i < count) {
val name = headers.name(i)
val value = headers.value(i)
if ("Accept-Encoding".equals(name, ignoreCase = true) && "gzip".equals(value,
ignoreCase = true)
) {
compressed = true
}
curlCmd.append(" -H " + "\"").append(name).append(": ").append(value).append("\"")
i++
}
val requestBody = request.body
if (requestBody != null) {
val buffer = Buffer()
requestBody.writeTo(buffer)
val contentType = requestBody.contentType()
val charset = contentType?.charset(StandardCharsets.UTF_8) ?: StandardCharsets.UTF_8
curlCmd.append(" --data $'")
.append(buffer.readString(charset).replace("\n", "\\n"))
.append("'")
}
curlCmd.append(if (compressed) " --compressed " else " ").append(request.url)
Log.d(TAG, "╭--- cURL (" + request.url + ")")
Log.d(TAG, curlCmd.toString())
Log.d(TAG, "╰--- (copy and paste the above line to a terminal)")
return chain.proceed(request)
}
}

View File

@@ -5,7 +5,6 @@ import okhttp3.CookieJar
import okhttp3.OkHttpClient
import org.koin.dsl.bind
import org.koin.dsl.module
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
@@ -22,9 +21,6 @@ val networkModule
cache(get<LocalStorageManager>().createHttpCache())
addInterceptor(UserAgentInterceptor())
addInterceptor(CloudFlareInterceptor())
if (BuildConfig.DEBUG) {
addNetworkInterceptor(CurlLoggingInterceptor())
}
}.build()
}
single<MangaLoaderContext> { MangaLoaderContextImpl(get(), get(), get()) }

View File

@@ -24,7 +24,7 @@ class ShortcutsRepository(
private val context: Context,
private val coil: ImageLoader,
private val historyRepository: HistoryRepository,
private val mangaRepository: MangaDataRepository
private val mangaRepository: MangaDataRepository,
) {
private val iconSize by lazy {

View File

@@ -165,6 +165,18 @@ class AppSettings(context: Context) {
else -> SimpleDateFormat(format, Locale.getDefault())
}
fun getSuggestionsTagsBlacklistRegex(): Regex? {
val string = prefs.getString(KEY_SUGGESTIONS_EXCLUDE_TAGS, null)?.trimEnd(' ', ',')
if (string.isNullOrEmpty()) {
return null
}
val tags = string.split(',')
val regex = tags.joinToString(prefix = "(", separator = "|", postfix = ")") { tag ->
Regex.escape(tag.trim())
}
return Regex(regex, RegexOption.IGNORE_CASE)
}
fun getMangaSources(includeHidden: Boolean): List<MangaSource> {
val list = MangaSource.values().toMutableList()
list.remove(MangaSource.LOCAL)
@@ -247,17 +259,16 @@ class AppSettings(context: Context) {
const val KEY_PAGES_PRELOAD = "pages_preload"
const val KEY_SUGGESTIONS = "suggestions"
const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw"
const val KEY_SUGGESTIONS_EXCLUDE_TAGS = "suggestions_exclude_tags"
const val KEY_SEARCH_SINGLE_SOURCE = "search_single_source"
// About
const val KEY_APP_UPDATE = "app_update"
const val KEY_APP_UPDATE_AUTO = "app_update_auto"
const val KEY_APP_TRANSLATION = "about_app_translation"
const val KEY_APP_GRATITUDES = "about_gratitudes"
const val KEY_FEEDBACK_4PDA = "about_feedback_4pda"
const val KEY_FEEDBACK_DISCORD = "about_feedback_discord"
const val KEY_FEEDBACK_GITHUB = "about_feedback_github"
const val KEY_SUPPORT_DEVELOPER = "about_support_developer"
private const val NETWORK_NEVER = 0
private const val NETWORK_ALWAYS = 1

View File

@@ -178,9 +178,7 @@ class ChaptersFragment :
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
val manga = viewModel.manga.value
mode.menuInflater.inflate(R.menu.mode_chapters, menu)
mode.title = manga?.title
return true
}
@@ -190,12 +188,7 @@ class ChaptersFragment :
menu.findItem(R.id.action_save).isVisible = items.none { x ->
x.chapter.source == MangaSource.LOCAL
}
mode.subtitle = resources.getQuantityString(
R.plurals.chapters_from_x,
items.size,
items.size,
chaptersAdapter?.itemCount ?: 0
)
mode.title = items.size.toString()
return true
}

View File

@@ -4,7 +4,6 @@ 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
import android.view.MenuItem
@@ -17,6 +16,7 @@ import androidx.appcompat.view.ActionMode
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.Insets
import androidx.core.net.toFile
import androidx.core.net.toUri
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
@@ -163,7 +163,7 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
R.id.action_share -> {
viewModel.manga.value?.let {
if (it.source == MangaSource.LOCAL) {
ShareHelper(this).shareCbz(Uri.parse(it.url).toFile())
ShareHelper(this).shareCbz(listOf(it.url.toUri().toFile()))
} else {
ShareHelper(this).shareMangaLink(it)
}

View File

@@ -24,11 +24,11 @@ import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.iterator
import org.koitharu.kotatsu.utils.ext.mapToSet
import java.io.IOException
class DetailsViewModel(

View File

@@ -6,7 +6,7 @@ import android.view.ViewGroup
import android.widget.BaseAdapter
import android.widget.TextView
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.utils.ext.replaceWith
import org.koitharu.kotatsu.parsers.util.replaceWith
class BranchesAdapter : BaseAdapter() {

View File

@@ -2,69 +2,32 @@ package org.koitharu.kotatsu.details.ui.adapter
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Rect
import androidx.core.content.ContextCompat
import androidx.core.view.children
import android.graphics.RectF
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.decor.AbstractSelectionItemDecoration
import org.koitharu.kotatsu.utils.ext.getThemeColor
import com.google.android.material.R as materialR
class ChaptersSelectionDecoration(context: Context) : RecyclerView.ItemDecoration() {
class ChaptersSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() {
private val bounds = Rect()
private val selection = HashSet<Long>()
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val radius = context.resources.getDimension(materialR.dimen.abc_control_corner_material)
init {
paint.color = ContextCompat.getColor(context, R.color.selector_foreground)
paint.color = context.getThemeColor(materialR.attr.colorSecondaryContainer, Color.LTGRAY)
paint.style = Paint.Style.FILL
}
val checkedItemsCount: Int
get() = selection.size
val checkedItemsIds: Set<Long>
get() = selection
fun toggleItemChecked(id: Long) {
if (!selection.remove(id)) {
selection.add(id)
}
}
fun setItemIsChecked(id: Long, isChecked: Boolean) {
if (isChecked) {
selection.add(id)
} else {
selection.remove(id)
}
}
fun checkAll(ids: Collection<Long>) {
selection.addAll(ids)
}
fun clearSelection() {
selection.clear()
}
override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
canvas.save()
if (parent.clipToPadding) {
canvas.clipRect(
parent.paddingLeft, parent.paddingTop, parent.width - parent.paddingRight,
parent.height - parent.paddingBottom
)
}
for (child in parent.children) {
val itemId = parent.getChildItemId(child)
if (itemId in selection) {
parent.getDecoratedBoundsWithMargins(child, bounds)
bounds.offset(child.translationX.toInt(), child.translationY.toInt())
canvas.drawRect(bounds, paint)
}
}
canvas.restore()
override fun onDrawBackground(
canvas: Canvas,
parent: RecyclerView,
child: View,
bounds: RectF,
state: RecyclerView.State,
) {
canvas.drawRoundRect(bounds, radius, radius, paint)
}
}

View File

@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.onEach
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemDownloadBinding
import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.utils.ext.*
import org.koitharu.kotatsu.utils.progress.ProgressJob

View File

@@ -16,8 +16,8 @@ import org.koitharu.kotatsu.details.ui.DetailsActivity
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.parsers.util.format
import org.koitharu.kotatsu.utils.PendingIntentCompat
import org.koitharu.kotatsu.utils.ext.format
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import com.google.android.material.R as materialR

View File

@@ -10,6 +10,7 @@ import android.os.PowerManager
import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.mapLatest
@@ -34,7 +35,6 @@ 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
import kotlin.collections.set
class DownloadService : BaseService() {
@@ -70,7 +70,6 @@ class DownloadService : BaseService() {
return if (manga != null) {
jobs[startId] = downloadManga(startId, manga, chapters)
jobCount.value = jobs.size
Toast.makeText(this, R.string.manga_downloading_, Toast.LENGTH_SHORT).show()
START_REDELIVER_INTENT
} else {
stopSelf(startId)
@@ -184,9 +183,33 @@ class DownloadService : BaseService() {
intent.putExtra(EXTRA_CHAPTERS_IDS, chaptersIds.toLongArray())
}
ContextCompat.startForegroundService(context, intent)
Toast.makeText(context, R.string.manga_downloading_, Toast.LENGTH_SHORT).show()
}
}
fun start(context: Context, manga: Collection<Manga>) {
if (manga.isEmpty()) {
return
}
confirmDataTransfer(context) {
for (item in manga) {
val intent = Intent(context, DownloadService::class.java)
intent.putExtra(EXTRA_MANGA, ParcelableManga(item))
ContextCompat.startForegroundService(context, intent)
}
}
}
fun confirmAndStart(context: Context, items: Set<Manga>) {
MaterialAlertDialogBuilder(context)
.setTitle(R.string.save_manga)
.setMessage(R.string.batch_manga_save_confirm)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.save) { _, _ ->
start(context, items)
}.show()
}
fun getCancelIntent(startId: Int) = Intent(ACTION_DOWNLOAD_CANCEL)
.putExtra(EXTRA_CANCEL_ID, startId)

View File

@@ -24,9 +24,10 @@ class ForegroundNotificationSwitcher(
@Synchronized
fun notify(startId: Int, notification: Notification) {
if (notifications.isEmpty()) {
handler.postDelayed(StartForegroundRunnable(startId, notification), DEFAULT_DELAY)
StartForegroundRunnable(startId, notification)
} else {
notificationManager.notify(startId, notification)
}
notificationManager.notify(startId, notification)
notifications[startId] = notification
}

View File

@@ -0,0 +1,14 @@
package org.koitharu.kotatsu.favourites.data
import java.util.*
import org.koitharu.kotatsu.core.db.entity.SortOrder
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.parsers.model.SortOrder
fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong()) = FavouriteCategory(
id = id,
title = title,
sortKey = sortKey,
order = SortOrder(order, SortOrder.NEWEST),
createdAt = Date(createdAt),
)

View File

@@ -3,9 +3,6 @@ package org.koitharu.kotatsu.favourites.data
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.parsers.model.SortOrder
import java.util.*
@Entity(tableName = "favourite_categories")
class FavouriteCategoryEntity(
@@ -15,13 +12,4 @@ class FavouriteCategoryEntity(
@ColumnInfo(name = "sort_key") val sortKey: Int,
@ColumnInfo(name = "title") val title: String,
@ColumnInfo(name = "order") val order: String,
) {
fun toFavouriteCategory(id: Long? = null) = FavouriteCategory(
id = id ?: categoryId.toLong(),
title = title,
sortKey = sortKey,
order = SortOrder.values().find { x -> x.name == order } ?: SortOrder.NEWEST,
createdAt = Date(createdAt),
)
}
)

View File

@@ -6,36 +6,35 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.core.db.entity.*
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
import org.koitharu.kotatsu.favourites.data.toFavouriteCategory
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.utils.ext.mapItems
import org.koitharu.kotatsu.utils.ext.mapToSet
class FavouritesRepository(private val db: MangaDatabase) {
suspend fun getAllManga(): List<Manga> {
val entities = db.favouritesDao.findAll()
return entities.map { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
return entities.map { it.manga.toManga(it.tags.toMangaTags()) }
}
fun observeAll(order: SortOrder): Flow<List<Manga>> {
return db.favouritesDao.observeAll(order)
.mapItems { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
.mapItems { it.manga.toManga(it.tags.toMangaTags()) }
}
suspend fun getManga(categoryId: Long): List<Manga> {
val entities = db.favouritesDao.findAll(categoryId)
return entities.map { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
return entities.map { it.manga.toManga(it.tags.toMangaTags()) }
}
fun observeAll(categoryId: Long, order: SortOrder): Flow<List<Manga>> {
return db.favouritesDao.observeAll(categoryId, order)
.mapItems { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
.mapItems { it.manga.toManga(it.tags.toMangaTags()) }
}
fun observeAll(categoryId: Long): Flow<List<Manga>> {
@@ -43,21 +42,6 @@ class FavouritesRepository(private val db: MangaDatabase) {
.flatMapLatest { order -> observeAll(categoryId, order) }
}
suspend fun getManga(categoryId: Long, offset: Int): List<Manga> {
val entities = db.favouritesDao.findAll(categoryId, offset, 20)
return entities.map { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
}
suspend fun getAllCategories(): List<FavouriteCategory> {
val entities = db.favouriteCategoriesDao.findAll()
return entities.map { it.toFavouriteCategory() }
}
suspend fun getCategories(mangaId: Long): List<FavouriteCategory> {
val entities = db.favouritesDao.find(mangaId)?.categories
return entities?.map { it.toFavouriteCategory() }.orEmpty()
}
fun observeCategories(): Flow<List<FavouriteCategory>> {
return db.favouriteCategoriesDao.observeAll().mapItems {
it.toFavouriteCategory()
@@ -70,8 +54,8 @@ class FavouritesRepository(private val db: MangaDatabase) {
}.distinctUntilChanged()
}
fun observeCategoriesIds(mangaId: Long): Flow<List<Long>> {
return db.favouritesDao.observeIds(mangaId)
fun observeCategoriesIds(mangaId: Long): Flow<Set<Long>> {
return db.favouritesDao.observeIds(mangaId).map { it.toSet() }
}
suspend fun addCategory(title: String): FavouriteCategory {
@@ -107,27 +91,37 @@ class FavouritesRepository(private val db: MangaDatabase) {
}
}
suspend fun addToCategory(manga: Manga, categoryId: Long) {
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag)
suspend fun addToCategory(categoryId: Long, mangas: Collection<Manga>) {
db.withTransaction {
db.tagsDao.upsert(tags)
db.mangaDao.upsert(MangaEntity.from(manga), tags)
val entity = FavouriteEntity(manga.id, categoryId, System.currentTimeMillis())
db.favouritesDao.insert(entity)
for (manga in mangas) {
val tags = manga.tags.toEntities()
db.tagsDao.upsert(tags)
db.mangaDao.upsert(manga.toEntity(), tags)
val entity = FavouriteEntity(manga.id, categoryId, System.currentTimeMillis())
db.favouritesDao.insert(entity)
}
}
}
suspend fun removeFromCategory(manga: Manga, categoryId: Long) {
db.favouritesDao.delete(categoryId, manga.id)
suspend fun removeFromFavourites(ids: Collection<Long>) {
db.withTransaction {
for (id in ids) {
db.favouritesDao.delete(id)
}
}
}
suspend fun removeFromFavourites(manga: Manga) {
db.favouritesDao.delete(manga.id)
suspend fun removeFromCategory(categoryId: Long, ids: Collection<Long>) {
db.withTransaction {
for (id in ids) {
db.favouritesDao.delete(categoryId, id)
}
}
}
private fun observeOrder(categoryId: Long): Flow<SortOrder> {
return db.favouriteCategoriesDao.observe(categoryId)
.map { x -> SortOrder.values().find { it.name == x.order } ?: SortOrder.NEWEST }
.map { x -> SortOrder(x.order, SortOrder.NEWEST) }
.distinctUntilChanged()
}
}

View File

@@ -2,14 +2,19 @@ package org.koitharu.kotatsu.favourites.ui
import android.os.Bundle
import android.view.*
import androidx.appcompat.view.ActionMode
import androidx.core.graphics.Insets
import androidx.core.view.children
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import java.util.*
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.util.ActionModeListener
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.ui.titleRes
import org.koitharu.kotatsu.databinding.FragmentFavouritesBinding
@@ -21,10 +26,12 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.measureHeight
import org.koitharu.kotatsu.utils.ext.showPopupMenu
import java.util.*
class FavouritesContainerFragment : BaseFragment<FragmentFavouritesBinding>(),
FavouritesTabLongClickListener, CategoriesEditDelegate.CategoriesEditCallback {
class FavouritesContainerFragment :
BaseFragment<FragmentFavouritesBinding>(),
FavouritesTabLongClickListener,
CategoriesEditDelegate.CategoriesEditCallback,
ActionModeListener {
private val viewModel by viewModel<FavouritesCategoriesViewModel>()
private val editDelegate by lazy(LazyThreadSafetyMode.NONE) {
@@ -51,6 +58,7 @@ class FavouritesContainerFragment : BaseFragment<FragmentFavouritesBinding>(),
binding.pager.adapter = adapter
pagerAdapter = adapter
TabLayoutMediator(binding.tabs, binding.pager, adapter).attach()
actionModeDelegate.addListener(this, viewLifecycleOwner)
viewModel.categories.observe(viewLifecycleOwner, ::onCategoriesChanged)
viewModel.onError.observe(viewLifecycleOwner, ::onError)
@@ -61,6 +69,16 @@ class FavouritesContainerFragment : BaseFragment<FragmentFavouritesBinding>(),
super.onDestroyView()
}
override fun onActionModeStarted(mode: ActionMode) {
binding.pager.isUserInputEnabled = false
binding.tabs.setTabsEnabled(false)
}
override fun onActionModeFinished(mode: ActionMode) {
binding.pager.isUserInputEnabled = true
binding.tabs.setTabsEnabled(true)
}
override fun onWindowInsetsChanged(insets: Insets) {
val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top
binding.root.updatePadding(
@@ -146,18 +164,20 @@ class FavouritesContainerFragment : BaseFragment<FragmentFavouritesBinding>(),
private fun createOrderSubmenu(menu: Menu, category: FavouriteCategory) {
val submenu = menu.findItem(R.id.action_order)?.subMenu ?: return
for ((i, item) in CategoriesActivity.SORT_ORDERS.withIndex()) {
val menuItem = submenu.add(
R.id.group_order,
Menu.NONE,
i,
item.titleRes
)
val menuItem = submenu.add(R.id.group_order, Menu.NONE, i, item.titleRes)
menuItem.isCheckable = true
menuItem.isChecked = item == category.order
}
submenu.setGroupCheckable(R.id.group_order, true, true)
}
private fun TabLayout.setTabsEnabled(enabled: Boolean) {
val tabStrip = getChildAt(0) as? ViewGroup ?: return
for (tab in tabStrip.children) {
tab.isEnabled = enabled
}
}
companion object {
fun newInstance() = FavouritesContainerFragment()

View File

@@ -2,18 +2,20 @@ package org.koitharu.kotatsu.favourites.ui.categories.select
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.FragmentManager
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.base.ui.BaseBottomSheet
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.model.withoutChapters
import org.koitharu.kotatsu.databinding.DialogFavoriteCategoriesBinding
import org.koitharu.kotatsu.favourites.ui.categories.CategoriesEditDelegate
import org.koitharu.kotatsu.favourites.ui.categories.select.adapter.MangaCategoriesAdapter
@@ -26,10 +28,10 @@ class FavouriteCategoriesDialog :
BaseBottomSheet<DialogFavoriteCategoriesBinding>(),
OnListItemClickListener<MangaCategoryItem>,
CategoriesEditDelegate.CategoriesEditCallback,
View.OnClickListener {
Toolbar.OnMenuItemClickListener {
private val viewModel by viewModel<MangaCategoriesViewModel> {
parametersOf(requireNotNull(arguments?.getParcelable<ParcelableManga>(MangaIntent.KEY_MANGA)).manga)
parametersOf(requireNotNull(arguments?.getParcelableArrayList<ParcelableManga>(KEY_MANGA_LIST)).map { it.manga })
}
private var adapter: MangaCategoriesAdapter? = null
@@ -46,7 +48,7 @@ class FavouriteCategoriesDialog :
super.onViewCreated(view, savedInstanceState)
adapter = MangaCategoriesAdapter(this)
binding.recyclerViewCategories.adapter = adapter
binding.textViewAdd.setOnClickListener(this)
binding.toolbar.setOnMenuItemClickListener(this)
viewModel.content.observe(viewLifecycleOwner, this::onContentChanged)
viewModel.onError.observe(viewLifecycleOwner, ::onError)
@@ -57,9 +59,13 @@ class FavouriteCategoriesDialog :
super.onDestroyView()
}
override fun onClick(v: View) {
when (v.id) {
R.id.textView_add -> editDelegate.createCategory()
override fun onMenuItemClick(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_create -> {
editDelegate.createCategory()
true
}
else -> false
}
}
@@ -86,10 +92,15 @@ class FavouriteCategoriesDialog :
companion object {
private const val TAG = "FavouriteCategoriesDialog"
private const val KEY_MANGA_LIST = "manga_list"
fun show(fm: FragmentManager, manga: Manga) = FavouriteCategoriesDialog()
.withArgs(1) {
putParcelable(MangaIntent.KEY_MANGA, ParcelableManga(manga))
}.show(fm, TAG)
fun show(fm: FragmentManager, manga: Manga) = Companion.show(fm, listOf(manga))
fun show(fm: FragmentManager, manga: Collection<Manga>) = FavouriteCategoriesDialog().withArgs(1) {
putParcelableArrayList(
KEY_MANGA_LIST,
manga.mapTo(ArrayList(manga.size)) { ParcelableManga(it.withoutChapters()) }
)
}.show(fm, TAG)
}
}

View File

@@ -4,19 +4,20 @@ import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.model.ids
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
class MangaCategoriesViewModel(
private val manga: Manga,
private val manga: List<Manga>,
private val favouritesRepository: FavouritesRepository
) : BaseViewModel() {
val content = combine(
favouritesRepository.observeCategories(),
favouritesRepository.observeCategoriesIds(manga.id)
observeCategoriesIds(),
) { all, checked ->
all.map {
MangaCategoryItem(
@@ -30,9 +31,9 @@ class MangaCategoriesViewModel(
fun setChecked(categoryId: Long, isChecked: Boolean) {
launchJob(Dispatchers.Default) {
if (isChecked) {
favouritesRepository.addToCategory(manga, categoryId)
favouritesRepository.addToCategory(categoryId, manga)
} else {
favouritesRepository.removeFromCategory(manga, categoryId)
favouritesRepository.removeFromCategory(categoryId, manga.ids())
}
}
}
@@ -42,4 +43,25 @@ class MangaCategoriesViewModel(
favouritesRepository.addCategory(name)
}
}
private fun observeCategoriesIds() = if (manga.size == 1) {
// Fast path
favouritesRepository.observeCategoriesIds(manga[0].id)
} else {
combine(
manga.map { favouritesRepository.observeCategoriesIds(it.id) }
) { array ->
val result = HashSet<Long>()
var isFirst = true
for (ids in array) {
if (isFirst) {
result.addAll(ids)
isFirst = false
} else {
result.retainAll(ids.toSet())
}
}
result
}
}
}

View File

@@ -1,13 +1,12 @@
package org.koitharu.kotatsu.favourites.ui.list
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.appcompat.view.ActionMode
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.withArgs
class FavouritesListFragment : MangaListFragment() {
@@ -23,17 +22,20 @@ class FavouritesListFragment : MangaListFragment() {
override fun onScrolledToEnd() = Unit
override fun onCreatePopupMenu(inflater: MenuInflater, menu: Menu, data: Manga) {
super.onCreatePopupMenu(inflater, menu, data)
inflater.inflate(R.menu.popup_favourites, menu)
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.mode_favourites, menu)
return super.onCreateActionMode(mode, menu)
}
override fun onPopupMenuItemSelected(item: MenuItem, data: Manga) = when (item.itemId) {
R.id.action_remove -> {
viewModel.removeFromFavourites(data)
true
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_remove -> {
viewModel.removeFromFavourites(selectedItemsIds)
mode.finish()
true
}
else -> super.onActionItemClicked(mode, item)
}
else -> super.onPopupMenuItemSelected(item, data)
}
companion object {

View File

@@ -13,7 +13,6 @@ import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
@@ -56,12 +55,15 @@ class FavouritesListViewModel(
override fun onRetry() = Unit
fun removeFromFavourites(manga: Manga) {
fun removeFromFavourites(ids: Set<Long>) {
if (ids.isEmpty()) {
return
}
launchJob {
if (categoryId == 0L) {
repository.removeFromFavourites(manga)
repository.removeFromFavourites(ids)
} else {
repository.removeFromCategory(manga, categoryId)
repository.removeFromCategory(categoryId, ids)
}
}
}

View File

@@ -0,0 +1,12 @@
package org.koitharu.kotatsu.history.data
import java.util.*
import org.koitharu.kotatsu.core.model.MangaHistory
fun HistoryEntity.toMangaHistory() = MangaHistory(
createdAt = Date(createdAt),
updatedAt = Date(updatedAt),
chapterId = chapterId,
page = page,
scroll = scroll.toInt()
)

View File

@@ -5,7 +5,6 @@ import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
@Dao
abstract class HistoryDao {
@@ -23,8 +22,15 @@ abstract class HistoryDao {
@Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM history)")
abstract suspend fun findAllManga(): List<MangaEntity>
@Query("SELECT * FROM tags WHERE tag_id IN (SELECT tag_id FROM manga_tags WHERE manga_id IN (SELECT manga_id FROM history))")
abstract suspend fun findAllTags(): List<TagEntity>
@Query(
"""SELECT tags.* FROM tags
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
INNER JOIN history ON history.manga_id = manga_tags.manga_id
GROUP BY manga_tags.tag_id
ORDER BY COUNT(manga_tags.manga_id) DESC
LIMIT :limit"""
)
abstract suspend fun findPopularTags(limit: Int): List<TagEntity>
@Query("SELECT * FROM history WHERE manga_id = :id")
abstract suspend fun find(id: Long): HistoryEntity?
@@ -32,6 +38,9 @@ abstract class HistoryDao {
@Query("SELECT * FROM history WHERE manga_id = :id")
abstract fun observe(id: Long): Flow<HistoryEntity?>
@Query("SELECT COUNT(*) FROM history")
abstract fun observeCount(): Flow<Int>
@Query("DELETE FROM history")
abstract suspend fun clear()
@@ -60,5 +69,4 @@ abstract class HistoryDao {
true
} else false
}
}

View File

@@ -5,11 +5,10 @@ import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.model.MangaHistory
import java.util.*
@Entity(
tableName = "history", foreignKeys = [
tableName = "history",
foreignKeys = [
ForeignKey(
entity = MangaEntity::class,
parentColumns = ["manga_id"],
@@ -26,13 +25,4 @@ class HistoryEntity(
@ColumnInfo(name = "chapter_id") val chapterId: Long,
@ColumnInfo(name = "page") val page: Int,
@ColumnInfo(name = "scroll") val scroll: Float,
) {
fun toMangaHistory() = MangaHistory(
createdAt = Date(createdAt),
updatedAt = Date(updatedAt),
chapterId = chapterId,
page = page,
scroll = scroll.toInt()
)
}
)

View File

@@ -19,5 +19,5 @@ class HistoryWithManga(
entityColumn = "tag_id",
associateBy = Junction(MangaTagsEntity::class)
)
val tags: List<TagEntity>
val tags: List<TagEntity>,
)

View File

@@ -2,18 +2,18 @@ package org.koitharu.kotatsu.history.domain
import androidx.room.withTransaction
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.core.db.entity.*
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.history.data.toMangaHistory
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.ext.mapItems
import org.koitharu.kotatsu.utils.ext.mapToSet
class HistoryRepository(
private val db: MangaDatabase,
@@ -23,20 +23,25 @@ class HistoryRepository(
suspend fun getList(offset: Int, limit: Int = 20): List<Manga> {
val entities = db.historyDao.findAll(offset, limit)
return entities.map { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
return entities.map { it.manga.toManga(it.tags.toMangaTags()) }
}
suspend fun getLastOrNull(): Manga? {
val entity = db.historyDao.findAll(0, 1).firstOrNull() ?: return null
return entity.manga.toManga(entity.tags.toMangaTags())
}
fun observeAll(): Flow<List<Manga>> {
return db.historyDao.observeAll().mapItems {
it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag))
it.manga.toManga(it.tags.toMangaTags())
}
}
fun observeAllWithHistory(): Flow<List<MangaWithHistory>> {
return db.historyDao.observeAll().mapItems {
MangaWithHistory(
it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)),
it.history.toMangaHistory()
it.manga.toManga(it.tags.toMangaTags()),
it.history.toMangaHistory(),
)
}
}
@@ -47,14 +52,20 @@ class HistoryRepository(
}
}
fun observeHasItems(): Flow<Boolean> {
return db.historyDao.observeCount()
.map { it > 0 }
.distinctUntilChanged()
}
suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Int) {
if (manga.isNsfw && settings.isHistoryExcludeNsfw) {
return
}
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag)
val tags = manga.tags.toEntities()
db.withTransaction {
db.tagsDao.upsert(tags)
db.mangaDao.upsert(MangaEntity.from(manga), tags)
db.mangaDao.upsert(manga.toEntity(), tags)
db.historyDao.upsert(
HistoryEntity(
mangaId = manga.id,
@@ -62,7 +73,7 @@ class HistoryRepository(
updatedAt = System.currentTimeMillis(),
chapterId = chapterId,
page = page,
scroll = scroll.toFloat() // we migrate to int, but decide to not update database
scroll = scroll.toFloat(), // we migrate to int, but decide to not update database
)
)
trackingRepository.upsert(manga)
@@ -81,17 +92,25 @@ class HistoryRepository(
db.historyDao.delete(manga.id)
}
suspend fun delete(ids: Collection<Long>) {
db.withTransaction {
for (id in ids) {
db.historyDao.delete(id)
}
}
}
/**
* Try to replace one manga with another one
* Useful for replacing saved manga on deleting it with remove source
*/
suspend fun deleteOrSwap(manga: Manga, alternative: Manga?) {
if (alternative == null || db.mangaDao.update(MangaEntity.from(alternative)) <= 0) {
if (alternative == null || db.mangaDao.update(alternative.toEntity()) <= 0) {
db.historyDao.delete(manga.id)
}
}
suspend fun getAllTags(): Set<MangaTag> {
return db.historyDao.findAllTags().mapToSet { x -> x.toMangaTag() }
suspend fun getPopularTags(limit: Int): List<MangaTag> {
return db.historyDao.findPopularTags(limit).map { x -> x.toMangaTag() }
}
}

View File

@@ -5,13 +5,11 @@ import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.appcompat.view.ActionMode
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.ellipsize
class HistoryListFragment : MangaListFragment() {
@@ -20,7 +18,6 @@ class HistoryListFragment : MangaListFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.onItemRemoved.observe(viewLifecycleOwner, ::onItemRemoved)
viewModel.isGroupingEnabled.observe(viewLifecycleOwner) {
activity?.invalidateOptionsMenu()
}
@@ -59,30 +56,22 @@ class HistoryListFragment : MangaListFragment() {
}
}
override fun onCreatePopupMenu(inflater: MenuInflater, menu: Menu, data: Manga) {
super.onCreatePopupMenu(inflater, menu, data)
inflater.inflate(R.menu.popup_history, menu)
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.mode_history, menu)
return super.onCreateActionMode(mode, menu)
}
override fun onPopupMenuItemSelected(item: MenuItem, data: Manga): Boolean {
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_remove -> {
viewModel.removeFromHistory(data)
viewModel.removeFromHistory(selectedItemsIds)
mode.finish()
true
}
else -> super.onPopupMenuItemSelected(item, data)
else -> super.onActionItemClicked(mode, item)
}
}
private fun onItemRemoved(item: Manga) {
Snackbar.make(
binding.recyclerView, getString(
R.string._s_removed_from_history,
item.title.ellipsize(16)
), Snackbar.LENGTH_SHORT
).show()
}
companion object {
fun newInstance() = HistoryListFragment()

View File

@@ -2,6 +2,8 @@ package org.koitharu.kotatsu.history.ui
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import java.util.*
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import org.koitharu.kotatsu.R
@@ -13,14 +15,10 @@ import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.history.domain.MangaWithHistory
import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.daysDiff
import org.koitharu.kotatsu.utils.ext.onFirst
import java.util.*
import java.util.concurrent.TimeUnit
class HistoryListViewModel(
private val repository: HistoryRepository,
@@ -29,7 +27,6 @@ class HistoryListViewModel(
private val trackingRepository: TrackingRepository,
) : MangaListViewModel(settings) {
val onItemRemoved = SingleLiveEvent<Manga>()
val isGroupingEnabled = MutableLiveData<Boolean>()
private val historyGrouping = settings.observe()
@@ -72,10 +69,12 @@ class HistoryListViewModel(
}
}
fun removeFromHistory(manga: Manga) {
fun removeFromHistory(ids: Set<Long>) {
if (ids.isEmpty()) {
return
}
launchJob {
repository.delete(manga)
onItemRemoved.call(manga)
repository.delete(ids)
shortcutsRepository.updateShortcuts()
}
}

View File

@@ -3,9 +3,11 @@ package org.koitharu.kotatsu.list.ui
import android.os.Bundle
import android.view.*
import androidx.annotation.CallSuper
import androidx.appcompat.widget.PopupMenu
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.collection.ArraySet
import androidx.core.graphics.Insets
import androidx.core.view.GravityCompat
import androidx.core.view.isNotEmpty
import androidx.core.view.updatePadding
import androidx.recyclerview.widget.GridLayoutManager
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
@@ -24,24 +26,31 @@ 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.download.ui.service.DownloadService
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesDialog
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
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaItemModel
import org.koitharu.kotatsu.main.ui.AppBarOwner
import org.koitharu.kotatsu.main.ui.MainActivity
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.ext.*
abstract class MangaListFragment :
BaseFragment<FragmentListBinding>(),
PaginationScrollListener.Callback,
MangaListListener,
SwipeRefreshLayout.OnRefreshListener {
SwipeRefreshLayout.OnRefreshListener,
ActionMode.Callback {
private var listAdapter: MangaListAdapter? = null
private var paginationListener: PaginationScrollListener? = null
private var selectionDecoration: MangaSelectionDecoration? = null
private var actionMode: ActionMode? = null
private val spanResolver = MangaListSpanResolver()
private val spanSizeLookup = SpanSizeLookup()
private val listCommitCallback = Runnable {
@@ -51,6 +60,12 @@ abstract class MangaListFragment :
protected abstract val viewModel: MangaListViewModel
protected val selectedItemsIds: Set<Long>
get() = selectionDecoration?.checkedItemsIds?.toSet().orEmpty()
protected val selectedItems: Set<Manga>
get() = collectSelectedItems()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
@@ -68,10 +83,12 @@ abstract class MangaListFragment :
lifecycleOwner = viewLifecycleOwner,
listener = this,
)
selectionDecoration = MangaSelectionDecoration(view.context)
paginationListener = PaginationScrollListener(4, this)
with(binding.recyclerView) {
setHasFixedSize(true)
adapter = listAdapter
addItemDecoration(selectionDecoration!!)
addOnScrollListener(paginationListener!!)
}
with(binding.swipeRefreshLayout) {
@@ -91,6 +108,7 @@ abstract class MangaListFragment :
override fun onDestroyView() {
listAdapter = null
paginationListener = null
selectionDecoration = null
spanSizeLookup.invalidateCache()
super.onDestroyView()
}
@@ -109,22 +127,28 @@ abstract class MangaListFragment :
}
override fun onItemClick(item: Manga, view: View) {
if (selectionDecoration?.checkedItemsCount != 0) {
selectionDecoration?.toggleItemChecked(item.id)
if (selectionDecoration?.checkedItemsCount == 0) {
actionMode?.finish()
} else {
actionMode?.invalidate()
binding.recyclerView.invalidateItemDecorations()
}
return
}
startActivity(DetailsActivity.newIntent(context ?: return, item))
}
override fun onItemLongClick(item: Manga, view: View): Boolean {
val menu = PopupMenu(context ?: return false, view)
onCreatePopupMenu(menu.menuInflater, menu.menu, item)
return if (menu.menu.hasVisibleItems()) {
menu.setOnMenuItemClickListener {
onPopupMenuItemSelected(it, item)
}
menu.gravity = GravityCompat.END or Gravity.TOP
menu.show()
true
} else {
false
if (actionMode == null) {
actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this)
}
return actionMode?.also {
selectionDecoration?.setItemIsChecked(item.id, true)
binding.recyclerView.invalidateItemDecorations()
it.invalidate()
} != null
}
@CallSuper
@@ -238,12 +262,67 @@ abstract class MangaListFragment :
addOnLayoutChangeListener(spanResolver)
}
}
selectionDecoration?.let { addItemDecoration(it) }
}
}
protected open fun onCreatePopupMenu(inflater: MenuInflater, menu: Menu, data: Manga) = Unit
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
return menu.isNotEmpty()
}
protected open fun onPopupMenuItemSelected(item: MenuItem, data: Manga) = false
@CallSuper
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.title = selectionDecoration?.checkedItemsCount?.toString()
return true
}
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_select_all -> {
val ids = listAdapter?.items?.mapNotNull {
(it as? MangaItemModel)?.id
} ?: return false
selectionDecoration?.checkAll(ids)
binding.recyclerView.invalidateItemDecorations()
mode.invalidate()
true
}
R.id.action_share -> {
ShareHelper(requireContext()).shareMangaLinks(selectedItems)
mode.finish()
true
}
R.id.action_favourite -> {
FavouriteCategoriesDialog.show(childFragmentManager, selectedItems)
mode.finish()
true
}
R.id.action_save -> {
DownloadService.confirmAndStart(requireContext(), selectedItems)
mode.finish()
true
}
else -> false
}
}
override fun onDestroyActionMode(mode: ActionMode) {
selectionDecoration?.clearSelection()
binding.recyclerView.invalidateItemDecorations()
actionMode = null
}
private fun collectSelectedItems(): Set<Manga> {
val checkedIds = selectionDecoration?.checkedItemsIds ?: return emptySet()
val items = listAdapter?.items ?: return emptySet()
val result = ArraySet<Manga>(checkedIds.size)
for (item in items) {
if (item is MangaItemModel && item.id in checkedIds) {
result.add(item.manga)
}
}
return result
}
private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup() {

View File

@@ -0,0 +1,71 @@
package org.koitharu.kotatsu.list.ui
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.view.View
import androidx.cardview.widget.CardView
import androidx.core.content.ContextCompat
import androidx.core.graphics.ColorUtils
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.NO_ID
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.decor.AbstractSelectionItemDecoration
import org.koitharu.kotatsu.list.ui.model.MangaItemModel
import org.koitharu.kotatsu.utils.ext.getItem
import org.koitharu.kotatsu.utils.ext.getThemeColor
import com.google.android.material.R as materialR
class MangaSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val checkIcon = ContextCompat.getDrawable(context, materialR.drawable.ic_mtrl_checked_circle)
private val iconOffset = context.resources.getDimensionPixelOffset(R.dimen.grid_spacing_outer)
private val strokeColor = context.getThemeColor(materialR.attr.colorPrimary, Color.RED)
private val fillColor = ColorUtils.setAlphaComponent(
ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f),
0x74
)
init {
hasBackground = false
hasForeground = true
isIncludeDecorAndMargins = false
paint.strokeWidth = context.resources.getDimension(R.dimen.selection_stroke_width)
checkIcon?.setTint(strokeColor)
}
override fun getItemId(parent: RecyclerView, child: View): Long {
val holder = parent.getChildViewHolder(child) ?: return NO_ID
val item = holder.getItem<MangaItemModel>() ?: return NO_ID
return item.id
}
override fun onDrawForeground(
canvas: Canvas,
parent: RecyclerView,
child: View,
bounds: RectF,
state: RecyclerView.State,
) {
val radius = (child as? CardView)?.radius ?: 0f
paint.color = fillColor
paint.style = Paint.Style.FILL
canvas.drawRoundRect(bounds, radius, radius, paint)
paint.color = strokeColor
paint.style = Paint.Style.STROKE
canvas.drawRoundRect(bounds, radius, radius, paint)
checkIcon?.run {
setBounds(
(bounds.left + iconOffset).toInt(),
(bounds.top + iconOffset).toInt(),
(bounds.left + iconOffset + intrinsicWidth).toInt(),
(bounds.top + iconOffset + intrinsicHeight).toInt(),
)
draw(canvas)
}
}
}

View File

@@ -3,9 +3,9 @@ package org.koitharu.kotatsu.list.ui.model
import org.koitharu.kotatsu.parsers.model.Manga
data class MangaGridModel(
val id: Long,
override val id: Long,
val title: String,
val coverUrl: String,
val manga: Manga,
override val manga: Manga,
val counter: Int,
) : ListModel
) : MangaItemModel

View File

@@ -0,0 +1,9 @@
package org.koitharu.kotatsu.list.ui.model
import org.koitharu.kotatsu.parsers.model.Manga
sealed interface MangaItemModel : ListModel {
val id: Long
val manga: Manga
}

View File

@@ -3,12 +3,12 @@ package org.koitharu.kotatsu.list.ui.model
import org.koitharu.kotatsu.parsers.model.Manga
data class MangaListDetailedModel(
val id: Long,
override val id: Long,
val title: String,
val subtitle: String?,
val tags: String,
val coverUrl: String,
val rating: String?,
val manga: Manga,
override val manga: Manga,
val counter: Int,
) : ListModel
) : MangaItemModel

View File

@@ -3,10 +3,10 @@ package org.koitharu.kotatsu.list.ui.model
import org.koitharu.kotatsu.parsers.model.Manga
data class MangaListModel(
val id: Long,
override val id: Long,
val title: String,
val subtitle: String,
val coverUrl: String,
val manga: Manga,
override val manga: Manga,
val counter: Int,
) : ListModel
) : MangaItemModel

View File

@@ -9,6 +9,9 @@ import android.view.MenuItem
import android.view.View
import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.view.ActionMode
import androidx.core.net.toFile
import androidx.core.net.toUri
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import org.koin.androidx.viewmodel.ext.android.viewModel
@@ -16,8 +19,7 @@ import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.ellipsize
import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.progress.Progress
class LocalListFragment : MangaListFragment(), ActivityResultCallback<List<@JvmSuppressWildcards Uri>> {
@@ -46,7 +48,7 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback<List<@JvmS
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.onMangaRemoved.observe(viewLifecycleOwner, ::onItemRemoved)
viewModel.onMangaRemoved.observe(viewLifecycleOwner) { onItemRemoved() }
viewModel.importProgress.observe(viewLifecycleOwner, ::onImportProgressChanged)
}
@@ -97,35 +99,41 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback<List<@JvmS
viewModel.importFiles(result)
}
override fun onCreatePopupMenu(inflater: MenuInflater, menu: Menu, data: Manga) {
super.onCreatePopupMenu(inflater, menu, data)
inflater.inflate(R.menu.popup_local, menu)
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.mode_local, menu)
return super.onCreateActionMode(mode, menu)
}
override fun onPopupMenuItemSelected(item: MenuItem, data: Manga): Boolean {
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_delete -> {
MaterialAlertDialogBuilder(context ?: return false)
.setTitle(R.string.delete_manga)
.setMessage(getString(R.string.text_delete_local_manga, data.title))
.setPositiveButton(R.string.delete) { _, _ ->
viewModel.delete(data)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
R.id.action_remove -> {
showDeletionConfirm(selectedItemsIds, mode)
true
}
else -> super.onPopupMenuItemSelected(item, data)
R.id.action_share -> {
val files = selectedItems.map { it.url.toUri().toFile() }
ShareHelper(requireContext()).shareCbz(files)
mode.finish()
true
}
else -> super.onActionItemClicked(mode, item)
}
}
private fun onItemRemoved(item: Manga) {
Snackbar.make(
binding.recyclerView, getString(
R.string._s_deleted_from_local_storage,
item.title.ellipsize(16)
), Snackbar.LENGTH_SHORT
).show()
private fun showDeletionConfirm(ids: Set<Long>, mode: ActionMode) {
MaterialAlertDialogBuilder(context ?: return)
.setTitle(R.string.delete_manga)
.setMessage(getString(R.string.text_delete_local_manga_batch))
.setPositiveButton(R.string.delete) { _, _ ->
viewModel.delete(ids)
mode.finish()
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun onItemRemoved() {
Snackbar.make(binding.recyclerView, R.string.removal_completed, Snackbar.LENGTH_SHORT).show()
}
private fun onImportProgressChanged(progress: Progress?) {

View File

@@ -3,10 +3,12 @@ package org.koitharu.kotatsu.local.ui
import android.net.Uri
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import java.io.IOException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.os.ShortcutsRepository
@@ -19,7 +21,6 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.progress.Progress
import java.io.IOException
class LocalListViewModel(
private val repository: LocalMangaRepository,
@@ -28,7 +29,7 @@ class LocalListViewModel(
private val shortcutsRepository: ShortcutsRepository,
) : MangaListViewModel(settings) {
val onMangaRemoved = SingleLiveEvent<Manga>()
val onMangaRemoved = SingleLiveEvent<Unit>()
val importProgress = MutableLiveData<Progress?>(null)
private val listError = MutableStateFlow<Throwable?>(null)
private val mangaList = MutableStateFlow<List<Manga>?>(null)
@@ -87,18 +88,23 @@ class LocalListViewModel(
}
}
fun delete(manga: Manga) {
launchJob {
fun delete(ids: Set<Long>) {
launchLoadingJob {
withContext(Dispatchers.Default) {
val original = repository.getRemoteManga(manga)
repository.delete(manga) || throw IOException("Unable to delete file")
runCatching {
historyRepository.deleteOrSwap(manga, original)
val itemsToRemove = checkNotNull(mangaList.value).filter { it.id in ids }
for (manga in itemsToRemove) {
val original = repository.getRemoteManga(manga)
repository.delete(manga) || throw IOException("Unable to delete file")
runCatching {
historyRepository.deleteOrSwap(manga, original)
}
mangaList.update { list ->
list?.filterNot { it.id == manga.id }
}
}
mangaList.value = mangaList.value?.filterNot { it.id == manga.id }
}
shortcutsRepository.updateShortcuts()
onMangaRemoved.call(manga)
onMangaRemoved.call(Unit)
}
}

View File

@@ -8,18 +8,15 @@ import android.view.MenuItem
import android.view.View
import android.view.ViewGroup.MarginLayoutParams
import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.view.ActionMode
import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets
import androidx.core.view.GravityCompat
import androidx.core.view.ViewCompat
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.core.view.*
import androidx.drawerlayout.widget.DrawerLayout
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentTransaction
import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.navigation.NavigationView
@@ -134,6 +131,7 @@ class MainActivity :
viewModel.onOpenReader.observe(this, this::onOpenReader)
viewModel.onError.observe(this, this::onError)
viewModel.isLoading.observe(this, this::onLoadingStateChanged)
viewModel.isResumeEnabled.observe(this, this::onResumeEnabledChanged)
viewModel.remoteSources.observe(this, this::updateSideMenu)
viewModel.isSuggestionsEnabled.observe(this, this::setSuggestionsEnabled)
}
@@ -288,6 +286,16 @@ class MainActivity :
}.show()
}
override fun onSupportActionModeStarted(mode: ActionMode) {
super.onSupportActionModeStarted(mode)
adjustDrawerLock()
}
override fun onSupportActionModeFinished(mode: ActionMode) {
super.onSupportActionModeFinished(mode)
adjustDrawerLock()
}
private fun onOpenReader(manga: Manga) {
val options = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
ActivityOptions.makeClipRevealAnimation(
@@ -302,23 +310,15 @@ class MainActivity :
}
private fun onError(e: Throwable) {
Snackbar.make(binding.container, e.getDisplayMessage(resources), Snackbar.LENGTH_SHORT)
.show()
Snackbar.make(binding.container, e.getDisplayMessage(resources), Snackbar.LENGTH_SHORT).show()
}
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()
}
)
} else {
binding.fab.setImageResource(R.drawable.ic_read_fill)
}
}
private fun onResumeEnabledChanged(isEnabled: Boolean) {
adjustFabVisibility(isResumeEnabled = isEnabled)
}
private fun updateSideMenu(remoteSources: List<MangaSource>) {
@@ -372,14 +372,14 @@ class MainActivity :
}
private fun onSearchOpened() {
drawer?.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
drawerToggle?.isDrawerIndicatorEnabled = false
adjustDrawerLock()
adjustFabVisibility(isSearchOpened = true)
}
private fun onSearchClosed() {
drawer?.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED)
drawerToggle?.isDrawerIndicatorEnabled = true
adjustDrawerLock()
adjustFabVisibility(isSearchOpened = false)
}
@@ -397,9 +397,27 @@ class MainActivity :
}
private fun adjustFabVisibility(
isResumeEnabled: Boolean = viewModel.isResumeEnabled.value == true,
topFragment: Fragment? = supportFragmentManager.findFragmentByTag(TAG_PRIMARY),
isSearchOpened: Boolean = supportFragmentManager.findFragmentByTag(TAG_SEARCH)?.isVisible == true,
) {
if (!isSearchOpened && topFragment is HistoryListFragment) binding.fab.show() else binding.fab.hide()
val fab = binding.fab
if (isResumeEnabled && !isSearchOpened && topFragment is HistoryListFragment) {
if (!fab.isVisible) {
fab.show()
}
} else {
if (fab.isVisible) {
fab.hide()
}
}
}
private fun adjustDrawerLock() {
val drawer = drawer ?: return
val isLocked = actionModeDelegate.isActionModeStarted || (drawerToggle?.isDrawerIndicatorEnabled == false)
drawer.setDrawerLockMode(
if (isLocked) DrawerLayout.LOCK_MODE_LOCKED_CLOSED else DrawerLayout.LOCK_MODE_UNLOCKED
)
}
}

View File

@@ -27,6 +27,10 @@ class MainViewModel(
.map { settings.isSuggestionsEnabled }
.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
val isResumeEnabled = historyRepository
.observeHasItems()
.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
val remoteSources = settings.observe()
.filter { it == AppSettings.KEY_SOURCES_ORDER || it == AppSettings.KEY_SOURCES_HIDDEN }
.onStart { emit("") }
@@ -35,9 +39,8 @@ class MainViewModel(
fun openLastReader() {
launchLoadingJob {
val manga = historyRepository.getList(0, 1).firstOrNull()
?: throw EmptyHistoryException()
val manga = historyRepository.getLastOrNull() ?: throw EmptyHistoryException()
onOpenReader.call(manga)
}
}
}
}

View File

@@ -44,6 +44,7 @@ import org.koitharu.kotatsu.reader.ui.pager.standard.PagerReaderFragment
import org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonReaderFragment
import org.koitharu.kotatsu.reader.ui.thumbnails.OnPageSelectListener
import org.koitharu.kotatsu.reader.ui.thumbnails.PagesThumbnailsSheet
import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.utils.GridTouchHelper
import org.koitharu.kotatsu.utils.ScreenOrientationHelper
import org.koitharu.kotatsu.utils.ShareHelper
@@ -158,7 +159,7 @@ class ReaderActivity :
)
}
R.id.action_settings -> {
startActivity(SimpleSettingsActivity.newReaderSettingsIntent(this))
startActivity(SettingsActivity.newReaderSettingsIntent(this))
}
R.id.action_chapters -> {
ChaptersBottomSheet.show(

View File

@@ -1,80 +0,0 @@
package org.koitharu.kotatsu.reader.ui
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.ViewGroup
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>(), AppBarOwner {
override val appBar: AppBarLayout
get() = binding.appbar
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivitySettingsSimpleBinding.inflate(layoutInflater))
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportFragmentManager.commit {
replace(
R.id.container,
when (intent?.action) {
Intent.ACTION_MANAGE_NETWORK_USAGE -> NetworkSettingsFragment()
ACTION_READER -> ReaderSettingsFragment()
ACTION_SUGGESTIONS -> SuggestionsSettingsFragment()
ACTION_SOURCE -> SourceSettingsFragment.newInstance(
intent.getSerializableExtra(EXTRA_SOURCE) as? MangaSource ?: MangaSource.LOCAL
)
else -> MainSettingsFragment()
}
)
}
}
override fun onWindowInsetsChanged(insets: Insets) {
with(binding.toolbar) {
updatePadding(
left = insets.left,
right = insets.right
)
updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = insets.top
}
}
}
companion object {
private const val ACTION_READER =
"${BuildConfig.APPLICATION_ID}.action.MANAGE_READER_SETTINGS"
private const val ACTION_SUGGESTIONS =
"${BuildConfig.APPLICATION_ID}.action.MANAGE_SUGGESTIONS"
private const val ACTION_SOURCE =
"${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCE_SETTINGS"
private const val EXTRA_SOURCE = "source"
fun newReaderSettingsIntent(context: Context) =
Intent(context, SimpleSettingsActivity::class.java)
.setAction(ACTION_READER)
fun newSuggestionsSettingsIntent(context: Context) =
Intent(context, SimpleSettingsActivity::class.java)
.setAction(ACTION_SUGGESTIONS)
fun newSourceSettingsIntent(context: Context, source: MangaSource) =
Intent(context, SimpleSettingsActivity::class.java)
.setAction(ACTION_SOURCE)
.putExtra(EXTRA_SOURCE, source)
}
}

View File

@@ -3,13 +3,14 @@ package org.koitharu.kotatsu.remotelist.ui
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.appcompat.view.ActionMode
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.list.ui.filter.FilterBottomSheet
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.reader.ui.SimpleSettingsActivity
import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.utils.ext.serializableArgument
import org.koitharu.kotatsu.utils.ext.withArgs
@@ -34,10 +35,7 @@ class RemoteListFragment : MangaListFragment() {
return when (item.itemId) {
R.id.action_source_settings -> {
startActivity(
SimpleSettingsActivity.newSourceSettingsIntent(
context ?: return false,
source,
)
SettingsActivity.newSourceSettingsIntent(context ?: return false, source)
)
true
}
@@ -49,6 +47,11 @@ class RemoteListFragment : MangaListFragment() {
}
}
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.mode_remote, menu)
return super.onCreateActionMode(mode, menu)
}
override fun onFilterClick() {
FilterBottomSheet.show(childFragmentManager)
}

View File

@@ -10,6 +10,8 @@ import kotlinx.coroutines.flow.*
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTag
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.model.Manga
@@ -84,7 +86,7 @@ class MangaSearchRepository(
return when {
query.isNotEmpty() && source != null -> db.tagsDao.findTags(source.name, "%$query%", limit)
query.isNotEmpty() -> db.tagsDao.findTags("%$query%", limit)
source != null -> db.tagsDao.findTags(source.name, limit)
source != null -> db.tagsDao.findPopularTags(source.name, limit)
else -> db.tagsDao.findPopularTags(limit)
}.map {
it.toMangaTag()

View File

@@ -1,7 +1,10 @@
package org.koitharu.kotatsu.search.ui
import android.view.Menu
import androidx.appcompat.view.ActionMode
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.ext.serializableArgument
@@ -21,6 +24,11 @@ class SearchFragment : MangaListFragment() {
viewModel.loadNextPage()
}
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.mode_remote, menu)
return super.onCreateActionMode(mode, menu)
}
companion object {
private const val ARG_QUERY = "query"

View File

@@ -1,12 +1,14 @@
package org.koitharu.kotatsu.search.ui.global
import android.view.Menu
import androidx.appcompat.view.ActionMode
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.utils.ext.stringArgument
import org.koitharu.kotatsu.utils.ext.withArgs
class GlobalSearchFragment : MangaListFragment() {
override val viewModel by viewModel<GlobalSearchViewModel> {
@@ -17,6 +19,11 @@ class GlobalSearchFragment : MangaListFragment() {
override fun onScrolledToEnd() = Unit
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.mode_remote, menu)
return super.onCreateActionMode(mode, menu)
}
companion object {
private const val ARG_QUERY = "query"

View File

@@ -4,7 +4,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import org.koitharu.kotatsu.base.ui.widgets.ChipsView
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.ext.areItemsEquals
import org.koitharu.kotatsu.parsers.util.areItemsEquals
sealed interface SearchSuggestionItem {

View File

@@ -0,0 +1,100 @@
package org.koitharu.kotatsu.settings
import android.content.Intent
import android.content.SharedPreferences
import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AppCompatDelegate
import androidx.preference.ListPreference
import androidx.preference.Preference
import androidx.preference.TwoStatePreference
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.settings.protect.ProtectSetupActivity
import org.koitharu.kotatsu.settings.utils.SliderPreference
import org.koitharu.kotatsu.utils.ext.names
import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat
import java.util.*
class AppearanceSettingsFragment :
BasePreferenceFragment(R.string.appearance),
SharedPreferences.OnSharedPreferenceChangeListener {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_appearance)
findPreference<SliderPreference>(AppSettings.KEY_GRID_SIZE)?.run {
summary = "%d%%".format(value)
setOnPreferenceChangeListener { preference, newValue ->
preference.summary = "%d%%".format(newValue)
true
}
}
preferenceScreen?.findPreference<ListPreference>(AppSettings.KEY_LIST_MODE)?.run {
entryValues = ListMode.values().names()
setDefaultValueCompat(ListMode.GRID.name)
}
findPreference<Preference>(AppSettings.KEY_DYNAMIC_THEME)?.isVisible = AppSettings.isDynamicColorAvailable
findPreference<ListPreference>(AppSettings.KEY_DATE_FORMAT)?.run {
entryValues = resources.getStringArray(R.array.date_formats)
val now = Date().time
entries = entryValues.map { value ->
val formattedDate = settings.getDateFormat(value.toString()).format(now)
if (value == "") {
"${context.getString(R.string.system_default)} ($formattedDate)"
} else {
formattedDate
}
}.toTypedArray()
setDefaultValueCompat("")
summary = "%s"
}
findPreference<TwoStatePreference>(AppSettings.KEY_PROTECT_APP)
?.isChecked = !settings.appPassword.isNullOrEmpty()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
settings.subscribe(this)
}
override fun onDestroyView() {
settings.unsubscribe(this)
super.onDestroyView()
}
override fun onSharedPreferenceChanged(prefs: SharedPreferences?, key: String?) {
when (key) {
AppSettings.KEY_THEME -> {
AppCompatDelegate.setDefaultNightMode(settings.theme)
}
AppSettings.KEY_DYNAMIC_THEME -> {
findPreference<Preference>(key)?.setSummary(R.string.restart_required)
}
AppSettings.KEY_THEME_AMOLED -> {
findPreference<Preference>(key)?.setSummary(R.string.restart_required)
}
AppSettings.KEY_APP_PASSWORD -> {
findPreference<TwoStatePreference>(AppSettings.KEY_PROTECT_APP)
?.isChecked = !settings.appPassword.isNullOrEmpty()
}
}
}
override fun onPreferenceTreeClick(preference: Preference): Boolean {
return when (preference.key) {
AppSettings.KEY_PROTECT_APP -> {
val pref = (preference as? TwoStatePreference ?: return false)
if (pref.isChecked) {
pref.isChecked = false
startActivity(Intent(preference.context, ProtectSetupActivity::class.java))
} else {
settings.appPassword = null
}
true
}
else -> super.onPreferenceTreeClick(preference)
}
}
}

View File

@@ -0,0 +1,96 @@
package org.koitharu.kotatsu.settings
import android.content.SharedPreferences
import android.os.Bundle
import android.view.View
import androidx.preference.Preference
import java.io.File
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.base.ui.dialog.StorageSelectDialog
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.ext.getStorageName
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
class ContentSettingsFragment :
BasePreferenceFragment(R.string.content),
SharedPreferences.OnSharedPreferenceChangeListener,
StorageSelectDialog.OnStorageSelectListener {
private val storageManager by inject<LocalStorageManager>()
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_content)
findPreference<Preference>(AppSettings.KEY_SUGGESTIONS)?.setSummary(
if (settings.isSuggestionsEnabled) R.string.enabled else R.string.disabled
)
bindRemoteSourcesSummary()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
findPreference<Preference>(AppSettings.KEY_LOCAL_STORAGE)?.bindStorageName()
settings.subscribe(this)
}
override fun onDestroyView() {
settings.unsubscribe(this)
super.onDestroyView()
}
override fun onSharedPreferenceChanged(prefs: SharedPreferences?, key: String?) {
when (key) {
AppSettings.KEY_LOCAL_STORAGE -> {
findPreference<Preference>(key)?.bindStorageName()
}
AppSettings.KEY_SUGGESTIONS -> {
findPreference<Preference>(AppSettings.KEY_SUGGESTIONS)?.setSummary(
if (settings.isSuggestionsEnabled) R.string.enabled else R.string.disabled
)
}
AppSettings.KEY_SOURCES_HIDDEN -> {
bindRemoteSourcesSummary()
}
}
}
override fun onPreferenceTreeClick(preference: Preference): Boolean {
return when (preference.key) {
AppSettings.KEY_LOCAL_STORAGE -> {
val ctx = context ?: return false
StorageSelectDialog.Builder(ctx, storageManager, this)
.setTitle(preference.title ?: "")
.setNegativeButton(android.R.string.cancel)
.create()
.show()
true
}
else -> super.onPreferenceTreeClick(preference)
}
}
override fun onStorageSelected(file: File) {
settings.mangaStorageDir = file
}
private fun Preference.bindStorageName() {
viewLifecycleScope.launch {
val storage = storageManager.getDefaultWriteableDir()
summary = storage?.getStorageName(context) ?: getString(R.string.not_available)
}
}
private fun bindRemoteSourcesSummary() {
findPreference<Preference>(AppSettings.KEY_REMOTE_SOURCES)?.run {
val total = MangaSource.values().size - 1
summary = getString(
R.string.enabled_d_of_d, total - settings.hiddenSources.size, total
)
}
}
}

View File

@@ -1,182 +0,0 @@
package org.koitharu.kotatsu.settings
import android.content.ComponentName
import android.content.Intent
import android.content.SharedPreferences
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.appcompat.app.AppCompatDelegate
import androidx.preference.ListPreference
import androidx.preference.Preference
import androidx.preference.PreferenceScreen
import androidx.preference.TwoStatePreference
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.base.ui.dialog.StorageSelectDialog
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.settings.protect.ProtectSetupActivity
import org.koitharu.kotatsu.settings.utils.SliderPreference
import org.koitharu.kotatsu.utils.ext.getStorageName
import org.koitharu.kotatsu.utils.ext.names
import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
import java.io.File
import java.util.*
class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
SharedPreferences.OnSharedPreferenceChangeListener,
StorageSelectDialog.OnStorageSelectListener {
private val storageManager by inject<LocalStorageManager>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_main)
findPreference<SliderPreference>(AppSettings.KEY_GRID_SIZE)?.run {
summary = "%d%%".format(value)
setOnPreferenceChangeListener { preference, newValue ->
preference.summary = "%d%%".format(newValue)
true
}
}
preferenceScreen?.findPreference<ListPreference>(AppSettings.KEY_LIST_MODE)?.run {
entryValues = ListMode.values().names()
setDefaultValueCompat(ListMode.GRID.name)
}
findPreference<Preference>(AppSettings.KEY_DYNAMIC_THEME)?.isVisible =
AppSettings.isDynamicColorAvailable
findPreference<ListPreference>(AppSettings.KEY_DATE_FORMAT)?.run {
entryValues = resources.getStringArray(R.array.date_formats)
val now = Date().time
entries = entryValues.map { value ->
val formattedDate = settings.getDateFormat(value.toString()).format(now)
if (value == "") {
"${context.getString(R.string.system_default)} ($formattedDate)"
} else {
formattedDate
}
}.toTypedArray()
setDefaultValueCompat("")
summary = "%s"
}
findPreference<Preference>(AppSettings.KEY_SUGGESTIONS)?.setSummary(
if (settings.isSuggestionsEnabled) R.string.enabled else R.string.disabled
)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
findPreference<Preference>(AppSettings.KEY_LOCAL_STORAGE)?.bindStorageName()
findPreference<TwoStatePreference>(AppSettings.KEY_PROTECT_APP)?.isChecked =
!settings.appPassword.isNullOrEmpty()
settings.subscribe(this)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
super.onCreateOptionsMenu(menu, inflater)
inflater.inflate(R.menu.opt_settings, menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_leaks -> {
val intent = Intent()
intent.component = ComponentName(requireContext(), "leakcanary.internal.activity.LeakActivity")
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
startActivity(intent)
true
}
else -> super.onOptionsItemSelected(item)
}
}
override fun onDestroyView() {
settings.unsubscribe(this)
super.onDestroyView()
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) {
when (key) {
AppSettings.KEY_THEME -> {
AppCompatDelegate.setDefaultNightMode(settings.theme)
}
AppSettings.KEY_DYNAMIC_THEME -> {
findPreference<Preference>(key)?.setSummary(R.string.restart_required)
}
AppSettings.KEY_THEME_AMOLED -> {
findPreference<Preference>(key)?.setSummary(R.string.restart_required)
}
AppSettings.KEY_LOCAL_STORAGE -> {
findPreference<Preference>(key)?.bindStorageName()
}
AppSettings.KEY_APP_PASSWORD -> {
findPreference<TwoStatePreference>(AppSettings.KEY_PROTECT_APP)
?.isChecked = !settings.appPassword.isNullOrEmpty()
}
AppSettings.KEY_SUGGESTIONS -> {
findPreference<Preference>(AppSettings.KEY_SUGGESTIONS)?.setSummary(
if (settings.isSuggestionsEnabled) R.string.enabled else R.string.disabled
)
}
}
}
override fun onResume() {
super.onResume()
findPreference<PreferenceScreen>(AppSettings.KEY_REMOTE_SOURCES)?.run {
val total = MangaSource.values().size - 1
summary = getString(
R.string.enabled_d_of_d, total - settings.hiddenSources.size, total
)
}
}
override fun onPreferenceTreeClick(preference: Preference): Boolean {
return when (preference.key) {
AppSettings.KEY_LOCAL_STORAGE -> {
val ctx = context ?: return false
StorageSelectDialog.Builder(ctx, storageManager, this)
.setTitle(preference.title ?: "")
.setNegativeButton(android.R.string.cancel)
.create()
.show()
true
}
AppSettings.KEY_PROTECT_APP -> {
val pref = (preference as? TwoStatePreference ?: return false)
if (pref.isChecked) {
pref.isChecked = false
startActivity(Intent(preference.context, ProtectSetupActivity::class.java))
} else {
settings.appPassword = null
}
true
}
else -> super.onPreferenceTreeClick(preference)
}
}
override fun onStorageSelected(file: File) {
settings.mangaStorageDir = file
}
private fun Preference.bindStorageName() {
viewLifecycleScope.launch {
val storage = storageManager.getDefaultWriteableDir()
summary = storage?.getStorageName(context) ?: getString(R.string.not_available)
}
}
}

View File

@@ -4,9 +4,9 @@ import android.os.Bundle
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
class NetworkSettingsFragment : BasePreferenceFragment(R.string.settings) {
class RootSettingsFragment : BasePreferenceFragment(R.string.settings) {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
//TODO https://developer.android.com/training/basics/network-ops/managing
addPreferencesFromResource(R.xml.pref_root)
}
}

View File

@@ -1,9 +1,13 @@
package org.koitharu.kotatsu.settings
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentTransaction
@@ -11,6 +15,7 @@ import androidx.fragment.app.commit
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
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.base.ui.util.RecyclerViewOwner
@@ -34,9 +39,7 @@ class SettingsActivity :
supportActionBar?.setDisplayHomeAsUpEnabled(true)
if (supportFragmentManager.findFragmentById(R.id.container) == null) {
supportFragmentManager.commit {
replace(R.id.container, MainSettingsFragment())
}
openDefaultFragment()
}
}
@@ -55,6 +58,22 @@ class SettingsActivity :
super.onStop()
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.opt_settings, menu)
return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
R.id.action_leaks -> {
val intent = Intent()
intent.component = ComponentName(this, "leakcanary.internal.activity.LeakActivity")
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
startActivity(intent)
true
}
else -> super.onOptionsItemSelected(item)
}
override fun onBackStackChanged() {
val fragment = supportFragmentManager.findFragmentById(R.id.container) as? RecyclerViewOwner ?: return
val recyclerView = fragment.recyclerView
@@ -70,32 +89,66 @@ class SettingsActivity :
val fm = supportFragmentManager
val fragment = fm.fragmentFactory.instantiate(classLoader, pref.fragment ?: return false)
fragment.arguments = pref.extras
fragment.setTargetFragment(caller, 0)
// fragment.setTargetFragment(caller, 0)
openFragment(fragment)
return true
}
fun openMangaSourceSettings(mangaSource: MangaSource) {
openFragment(SourceSettingsFragment.newInstance(mangaSource))
override fun onWindowInsetsChanged(insets: Insets) {
binding.appbar.updatePadding(
left = insets.left,
right = insets.right,
)
binding.container.updatePadding(
left = insets.left,
right = insets.right,
)
}
fun openNotificationSettingsLegacy() {
openFragment(NotificationSettingsLegacyFragment())
}
private fun openFragment(fragment: Fragment) {
fun openFragment(fragment: Fragment) {
supportFragmentManager.commit {
setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN)
replace(R.id.container, fragment)
setReorderingAllowed(true)
replace(R.id.container, fragment)
setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
addToBackStack(null)
}
}
override fun onWindowInsetsChanged(insets: Insets) = Unit
private fun openDefaultFragment() {
val fragment = when (intent?.action) {
ACTION_READER -> ReaderSettingsFragment()
ACTION_SUGGESTIONS -> SuggestionsSettingsFragment()
ACTION_SOURCE -> SourceSettingsFragment.newInstance(
intent.getSerializableExtra(EXTRA_SOURCE) as? MangaSource ?: MangaSource.LOCAL
)
else -> SettingsHeadersFragment()
}
supportFragmentManager.commit {
setReorderingAllowed(true)
replace(R.id.container, fragment)
}
}
companion object {
private const val ACTION_READER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_READER_SETTINGS"
private const val ACTION_SUGGESTIONS = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SUGGESTIONS"
private const val ACTION_SOURCE = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCE_SETTINGS"
private const val EXTRA_SOURCE = "source"
fun newIntent(context: Context) = Intent(context, SettingsActivity::class.java)
fun newReaderSettingsIntent(context: Context) =
Intent(context, SettingsActivity::class.java)
.setAction(ACTION_READER)
fun newSuggestionsSettingsIntent(context: Context) =
Intent(context, SettingsActivity::class.java)
.setAction(ACTION_SUGGESTIONS)
fun newSourceSettingsIntent(context: Context, source: MangaSource) =
Intent(context, SettingsActivity::class.java)
.setAction(ACTION_SOURCE)
.putExtra(EXTRA_SOURCE, source)
}
}

View File

@@ -0,0 +1,49 @@
package org.koitharu.kotatsu.settings
import android.os.Bundle
import android.view.View
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentTransaction
import androidx.fragment.app.commit
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceHeaderFragmentCompat
import androidx.slidingpanelayout.widget.SlidingPaneLayout
import org.koitharu.kotatsu.R
class SettingsHeadersFragment : PreferenceHeaderFragmentCompat(), SlidingPaneLayout.PanelSlideListener {
private var currentTitle: CharSequence? = null
override fun onCreatePreferenceHeader(): PreferenceFragmentCompat = RootSettingsFragment()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
slidingPaneLayout.addPanelSlideListener(this)
}
override fun onPanelSlide(panel: View, slideOffset: Float) = Unit
override fun onPanelOpened(panel: View) {
activity?.title = currentTitle ?: getString(R.string.settings)
}
override fun onPanelClosed(panel: View) {
activity?.setTitle(R.string.settings)
}
fun setTitle(title: CharSequence?) {
currentTitle = title
if (slidingPaneLayout.isSlideable && slidingPaneLayout.isOpen) {
activity?.title = title
}
}
fun openFragment(fragment: Fragment) {
childFragmentManager.commit {
setReorderingAllowed(true)
replace(androidx.preference.R.id.preferences_detail, fragment)
setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
addToBackStack(null)
}
}
}

View File

@@ -25,7 +25,7 @@ class SourceSettingsFragment : BasePreferenceFragment(0) {
override fun onResume() {
super.onResume()
activity?.title = source.title
setTitle(source.title)
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {

View File

@@ -4,10 +4,13 @@ import android.content.SharedPreferences
import android.os.Bundle
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import org.koin.android.ext.android.get
import org.koin.android.ext.android.inject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.settings.utils.MultiAutoCompleteTextViewPreference
import org.koitharu.kotatsu.settings.utils.TagsAutoCompleteProvider
import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository
import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker
@@ -23,6 +26,11 @@ class SuggestionsSettingsFragment : BasePreferenceFragment(R.string.suggestions)
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_suggestions)
findPreference<MultiAutoCompleteTextViewPreference>(AppSettings.KEY_SUGGESTIONS_EXCLUDE_TAGS)?.run {
autoCompleteProvider = TagsAutoCompleteProvider(get())
summaryProvider = MultiAutoCompleteTextViewPreference.SimpleSummaryProvider(summary)
}
}
override fun onDestroy() {

View File

@@ -31,7 +31,6 @@ class TrackerSettingsFragment : BasePreferenceFragment(R.string.check_for_new_ch
append(getString(R.string.read_more))
}
}
warningPreference
}
}
@@ -43,10 +42,10 @@ class TrackerSettingsFragment : BasePreferenceFragment(R.string.check_for_new_ch
.putExtra(Settings.EXTRA_APP_PACKAGE, requireContext().packageName)
.putExtra(Settings.EXTRA_CHANNEL_ID, TrackWorker.CHANNEL_ID)
startActivity(intent)
true
} else {
(activity as? SettingsActivity)?.openNotificationSettingsLegacy()
super.onPreferenceTreeClick(preference)
}
true
}
else -> super.onPreferenceTreeClick(preference)
}

View File

@@ -1,13 +1,13 @@
package org.koitharu.kotatsu.settings.about
import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.core.net.toUri
import androidx.preference.Preference
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.browser.BrowserActivity
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.settings.AppUpdateChecker
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
@@ -16,20 +16,16 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_about)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val isUpdateSupported = AppUpdateChecker.isUpdateSupported(requireContext())
findPreference<Preference>(AppSettings.KEY_APP_UPDATE_AUTO)?.run {
isVisible = AppUpdateChecker.isUpdateSupported(context)
isVisible = isUpdateSupported
}
findPreference<Preference>(AppSettings.KEY_APP_VERSION)?.run {
title = getString(R.string.app_version, BuildConfig.VERSION_NAME)
isEnabled = AppUpdateChecker.isUpdateSupported(context)
isEnabled = isUpdateSupported
}
}
override fun onPreferenceTreeClick(preference: Preference): Boolean {
return when (preference.key) {
AppSettings.KEY_APP_VERSION -> {
@@ -37,39 +33,19 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
true
}
AppSettings.KEY_APP_TRANSLATION -> {
startActivity(context?.let { BrowserActivity.newIntent(it,
"https://hosted.weblate.org/engage/kotatsu",
resources.getString(R.string.about_app_translation)) })
openLink(getString(R.string.url_weblate), preference.title)
true
}
AppSettings.KEY_FEEDBACK_4PDA -> {
startActivity(context?.let { BrowserActivity.newIntent(it,
"https://4pda.to/forum/index.php?showtopic=697669",
resources.getString(R.string.about_feedback_4pda)) })
openLink(getString(R.string.url_forpda), preference.title)
true
}
AppSettings.KEY_FEEDBACK_DISCORD -> {
startActivity(context?.let { BrowserActivity.newIntent(it,
"https://discord.gg/NNJ5RgVBC5",
"Discord") })
openLink(getString(R.string.url_discord), preference.title)
true
}
AppSettings.KEY_FEEDBACK_GITHUB -> {
startActivity(context?.let { BrowserActivity.newIntent(it,
"https://github.com/nv95/Kotatsu/issues",
"GitHub") })
true
}
AppSettings.KEY_SUPPORT_DEVELOPER -> {
startActivity(context?.let { BrowserActivity.newIntent(it,
"https://yoomoney.ru/to/410012543938752",
resources.getString(R.string.about_support_developer)) })
true
}
AppSettings.KEY_APP_GRATITUDES -> {
startActivity(context?.let { BrowserActivity.newIntent(it,
"https://github.com/nv95/Kotatsu/graphs/contributors",
resources.getString(R.string.about_gratitudes)) })
openLink(getString(R.string.url_github_issues), preference.title)
true
}
else -> super.onPreferenceTreeClick(preference)
@@ -95,4 +71,16 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
}
}
}
}
private fun openLink(url: String, title: CharSequence?) {
val intent = Intent(Intent.ACTION_VIEW)
intent.data = url.toUri()
startActivity(
if (title != null) {
Intent.createChooser(intent, title)
} else {
intent
}
)
}
}

View File

@@ -1,41 +0,0 @@
package org.koitharu.kotatsu.settings.about
import android.os.Bundle
import android.text.SpannableStringBuilder
import android.text.method.LinkMovementMethod
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.graphics.Insets
import androidx.core.text.HtmlCompat
import androidx.core.text.parseAsHtml
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.databinding.FragmentCopyrightBinding
class LicenseFragment : BaseFragment<FragmentCopyrightBinding>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.textView.apply {
text =
SpannableStringBuilder(resources.openRawResource(R.raw.copyright).bufferedReader()
.readText()
.parseAsHtml(HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_LIST))
movementMethod = LinkMovementMethod.getInstance()
}
}
override fun onInflateView(
inflater: LayoutInflater,
container: ViewGroup?
) = FragmentCopyrightBinding.inflate(inflater, container, false)
override fun onResume() {
super.onResume()
activity?.setTitle(R.string.about_license)
}
override fun onWindowInsetsChanged(insets: Insets) = Unit
}

View File

@@ -0,0 +1,107 @@
package org.koitharu.kotatsu.settings.backup
import android.app.backup.BackupAgent
import android.app.backup.BackupDataInput
import android.app.backup.BackupDataOutput
import android.app.backup.FullBackupDataOutput
import android.os.ParcelFileDescriptor
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.runBlocking
import org.koitharu.kotatsu.core.backup.BackupArchive
import org.koitharu.kotatsu.core.backup.BackupEntry
import org.koitharu.kotatsu.core.backup.BackupRepository
import org.koitharu.kotatsu.core.backup.RestoreRepository
import org.koitharu.kotatsu.core.db.MangaDatabase
import java.io.*
class AppBackupAgent : BackupAgent() {
override fun onBackup(
oldState: ParcelFileDescriptor?,
data: BackupDataOutput?,
newState: ParcelFileDescriptor?
) = Unit
override fun onRestore(
data: BackupDataInput?,
appVersionCode: Int,
newState: ParcelFileDescriptor?
) = Unit
override fun onFullBackup(data: FullBackupDataOutput) {
super.onFullBackup(data)
val file = createBackupFile()
try {
fullBackupFile(file, data)
} finally {
file.delete()
}
}
override fun onRestoreFile(
data: ParcelFileDescriptor,
size: Long,
destination: File?,
type: Int,
mode: Long,
mtime: Long
) {
if (destination?.name?.endsWith(".bak") == true) {
restoreBackupFile(data.fileDescriptor, size)
destination.delete()
} else {
super.onRestoreFile(data, size, destination, type, mode, mtime)
}
}
private fun createBackupFile() = runBlocking {
val repository = BackupRepository(MangaDatabase.create(applicationContext))
val backup = BackupArchive.createNew(this@AppBackupAgent)
backup.put(repository.createIndex())
backup.put(repository.dumpHistory())
backup.put(repository.dumpCategories())
backup.put(repository.dumpFavourites())
backup.flush()
backup.cleanup()
backup.file
}
private fun restoreBackupFile(fd: FileDescriptor, size: Long) {
val repository = RestoreRepository(MangaDatabase.create(applicationContext))
val tempFile = File.createTempFile("backup_", ".tmp")
FileInputStream(fd).use { input ->
tempFile.outputStream().use { output ->
input.copyLimitedTo(output, size)
}
}
val backup = BackupArchive(tempFile)
try {
runBlocking {
backup.unpack()
repository.upsertHistory(backup.getEntry(BackupEntry.HISTORY))
repository.upsertCategories(backup.getEntry(BackupEntry.CATEGORIES))
repository.upsertFavourites(backup.getEntry(BackupEntry.FAVOURITES))
}
} finally {
runBlocking(NonCancellable) {
backup.cleanup()
}
tempFile.delete()
}
}
private fun InputStream.copyLimitedTo(out: OutputStream, limit: Long) {
var bytesCopied: Long = 0
val buffer = ByteArray(DEFAULT_BUFFER_SIZE.coerceAtMost(limit.toInt()))
var bytes = read(buffer)
while (bytes >= 0) {
out.write(buffer, 0, bytes)
bytesCopied += bytes
val bytesLeft = (limit - bytesCopied).toInt()
if (bytesLeft <= 0) {
break
}
bytes = read(buffer, 0, buffer.size.coerceAtMost(bytesLeft))
}
}
}

View File

@@ -6,6 +6,7 @@ import androidx.lifecycle.MutableLiveData
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.settings.onboard.model.SourceLocale
import org.koitharu.kotatsu.utils.ext.map

View File

@@ -15,6 +15,8 @@ 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.SettingsHeadersFragment
import org.koitharu.kotatsu.settings.SourceSettingsFragment
import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigAdapter
import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigListener
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
@@ -87,7 +89,9 @@ class SourcesSettingsFragment : BaseFragment<FragmentSettingsSourcesBinding>(),
}
override fun onItemSettingsClick(item: SourceConfigItem.SourceItem) {
(activity as? SettingsActivity)?.openMangaSourceSettings(item.source)
val fragment = SourceSettingsFragment.newInstance(item.source)
(parentFragment as? SettingsHeadersFragment)?.openFragment(fragment)
?: (activity as? SettingsActivity)?.openFragment(fragment)
}
override fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) {

View File

@@ -0,0 +1,118 @@
package org.koitharu.kotatsu.settings.utils
import android.content.Context
import android.util.AttributeSet
import android.widget.ArrayAdapter
import android.widget.EditText
import android.widget.Filter
import android.widget.MultiAutoCompleteTextView
import androidx.annotation.AttrRes
import androidx.annotation.MainThread
import androidx.annotation.StyleRes
import androidx.annotation.WorkerThread
import androidx.preference.EditTextPreference
import kotlinx.coroutines.runBlocking
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.util.replaceWith
class MultiAutoCompleteTextViewPreference @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@AttrRes defStyleAttr: Int = R.attr.multiAutoCompleteTextViewPreferenceStyle,
@StyleRes defStyleRes: Int = R.style.Preference_MultiAutoCompleteTextView,
) : EditTextPreference(context, attrs, defStyleAttr, defStyleRes) {
private val autoCompleteBindListener = AutoCompleteBindListener()
var autoCompleteProvider: AutoCompleteProvider? = null
init {
super.setOnBindEditTextListener(autoCompleteBindListener)
}
override fun setOnBindEditTextListener(onBindEditTextListener: OnBindEditTextListener?) {
autoCompleteBindListener.delegate = onBindEditTextListener
}
private inner class AutoCompleteBindListener : OnBindEditTextListener {
var delegate: OnBindEditTextListener? = null
override fun onBindEditText(editText: EditText) {
delegate?.onBindEditText(editText)
if (editText !is MultiAutoCompleteTextView) {
return
}
editText.setTokenizer(MultiAutoCompleteTextView.CommaTokenizer())
editText.setAdapter(
autoCompleteProvider?.let {
CompletionAdapter(editText.context, it, ArrayList())
}
)
editText.threshold = 1
}
}
interface AutoCompleteProvider {
suspend fun getSuggestions(query: String): List<String>
}
class SimpleSummaryProvider(
private val emptySummary: CharSequence?,
) : SummaryProvider<MultiAutoCompleteTextViewPreference> {
override fun provideSummary(preference: MultiAutoCompleteTextViewPreference): CharSequence? {
return if (preference.text.isNullOrEmpty()) {
emptySummary
} else {
preference.text?.trimEnd(' ', ',')
}
}
}
private class CompletionAdapter(
context: Context,
private val completionProvider: AutoCompleteProvider,
private val dataset: MutableList<String>,
) : ArrayAdapter<String>(context, android.R.layout.simple_dropdown_item_1line, dataset) {
override fun getFilter(): Filter {
return CompletionFilter(this, completionProvider)
}
fun publishResults(results: List<String>) {
dataset.replaceWith(results)
notifyDataSetChanged()
}
}
private class CompletionFilter(
private val adapter: CompletionAdapter,
private val provider: AutoCompleteProvider,
) : Filter() {
@WorkerThread
override fun performFiltering(constraint: CharSequence?): FilterResults {
val query = constraint?.toString().orEmpty()
val suggestions = runBlocking { provider.getSuggestions(query) }
return CompletionResults(suggestions)
}
@MainThread
override fun publishResults(constraint: CharSequence?, results: FilterResults) {
val completions = (results as CompletionResults).completions
adapter.publishResults(completions)
}
private class CompletionResults(
val completions: List<String>,
) : FilterResults() {
init {
values = completions
count = completions.size
}
}
}
}

View File

@@ -30,18 +30,22 @@ class SliderPreference @JvmOverloads constructor(
set(value) = setValueInternal(value, notifyChanged = true)
private val sliderListener = Slider.OnChangeListener { _, value, fromUser ->
if (fromUser) {
syncValueInternal(value.toInt())
}
if (fromUser) {
syncValueInternal(value.toInt())
}
}
init {
context.withStyledAttributes(attrs,
context.withStyledAttributes(
attrs,
R.styleable.SliderPreference,
defStyleAttr,
defStyleRes) {
valueFrom = getFloat(R.styleable.SliderPreference_android_valueFrom,
valueFrom.toFloat()).toInt()
defStyleRes
) {
valueFrom = getFloat(
R.styleable.SliderPreference_android_valueFrom,
valueFrom.toFloat()
).toInt()
valueTo =
getFloat(R.styleable.SliderPreference_android_valueTo, valueTo.toFloat()).toInt()
stepSize =

View File

@@ -0,0 +1,23 @@
package org.koitharu.kotatsu.settings.utils
import org.koitharu.kotatsu.core.db.MangaDatabase
class TagsAutoCompleteProvider(
private val db: MangaDatabase,
) : MultiAutoCompleteTextViewPreference.AutoCompleteProvider {
override suspend fun getSuggestions(query: String): List<String> {
if (query.isEmpty()) {
return emptyList()
}
val tags = db.tagsDao.findTags(query = "$query%", limit = 6)
val set = HashSet<String>()
val result = ArrayList<String>(tags.size)
for (tag in tags) {
if (set.add(tag.title)) {
result.add(tag.title)
}
}
return result
}
}

View File

@@ -24,4 +24,4 @@ class SuggestionEntity(
@FloatRange(from = 0.0, to = 1.0)
@ColumnInfo(name = "relevance") val relevance: Float,
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(),
)
)

View File

@@ -19,5 +19,5 @@ data class SuggestionWithManga(
entityColumn = "tag_id",
associateBy = Junction(MangaTagsEntity::class)
)
val tags: List<TagEntity>
val tags: List<TagEntity>,
)

View File

@@ -3,12 +3,13 @@ package org.koitharu.kotatsu.suggestions.domain
import androidx.room.withTransaction
import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.core.db.entity.toEntities
import org.koitharu.kotatsu.core.db.entity.toEntity
import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.suggestions.data.SuggestionEntity
import org.koitharu.kotatsu.utils.ext.mapItems
import org.koitharu.kotatsu.utils.ext.mapToSet
class SuggestionRepository(
private val db: MangaDatabase,
@@ -16,7 +17,7 @@ class SuggestionRepository(
fun observeAll(): Flow<List<Manga>> {
return db.suggestionDao.observeAll().mapItems {
it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag))
it.manga.toManga(it.tags.toMangaTags())
}
}
@@ -32,9 +33,9 @@ class SuggestionRepository(
db.withTransaction {
db.suggestionDao.deleteAll()
suggestions.forEach { x ->
val tags = x.manga.tags.map(TagEntity.Companion::fromMangaTag)
val tags = x.manga.tags.toEntities()
db.tagsDao.upsert(tags)
db.mangaDao.upsert(MangaEntity.from(x.manga), tags)
db.mangaDao.upsert(x.manga.toEntity(), tags)
db.suggestionDao.upsert(
SuggestionEntity(
mangaId = x.manga.id,

View File

@@ -4,11 +4,12 @@ import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.appcompat.view.ActionMode
import com.google.android.material.snackbar.Snackbar
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.reader.ui.SimpleSettingsActivity
import org.koitharu.kotatsu.settings.SettingsActivity
class SuggestionsFragment : MangaListFragment() {
@@ -37,7 +38,7 @@ class SuggestionsFragment : MangaListFragment() {
true
}
R.id.action_settings -> {
startActivity(SimpleSettingsActivity.newSuggestionsSettingsIntent(requireContext()))
startActivity(SettingsActivity.newSuggestionsSettingsIntent(requireContext()))
true
}
else -> super.onOptionsItemSelected(item)
@@ -46,6 +47,11 @@ class SuggestionsFragment : MangaListFragment() {
override fun onScrolledToEnd() = Unit
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.mode_remote, menu)
return super.onCreateActionMode(mode, menu)
}
companion object {
fun newInstance() = SuggestionsFragment()

View File

@@ -1,16 +1,29 @@
package org.koitharu.kotatsu.suggestions.ui
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import androidx.annotation.FloatRange
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.work.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.suggestions.domain.MangaSuggestion
import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository
import org.koitharu.kotatsu.utils.ext.asArrayList
import org.koitharu.kotatsu.utils.ext.trySetForeground
import java.util.concurrent.TimeUnit
import kotlin.math.pow
@@ -21,11 +34,41 @@ class SuggestionsWorker(appContext: Context, params: WorkerParameters) :
private val historyRepository by inject<HistoryRepository>()
private val appSettings by inject<AppSettings>()
override suspend fun doWork(): Result = try {
override suspend fun doWork(): Result {
val count = doWorkImpl()
Result.success(workDataOf(DATA_COUNT to count))
} catch (t: Throwable) {
Result.failure()
val outputData = workDataOf(DATA_COUNT to count)
return Result.success(outputData)
}
override suspend fun getForegroundInfo(): ForegroundInfo {
val manager = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val title = applicationContext.getString(R.string.suggestions_updating)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
WORKER_CHANNEL_ID,
title,
NotificationManager.IMPORTANCE_LOW
)
channel.setShowBadge(false)
channel.enableVibration(false)
channel.setSound(null, null)
channel.enableLights(false)
manager.createNotificationChannel(channel)
}
val notification = NotificationCompat.Builder(applicationContext, WORKER_CHANNEL_ID)
.setContentTitle(title)
.setPriority(NotificationCompat.PRIORITY_MIN)
.setDefaults(0)
.setColor(ContextCompat.getColor(applicationContext, R.color.blue_primary_dark))
.setSilent(true)
.setProgress(0, 0, true)
.setSmallIcon(android.R.drawable.stat_notify_sync)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_DEFERRED)
.setOngoing(true)
.build()
return ForegroundInfo(WORKER_NOTIFICATION_ID, notification)
}
private suspend fun doWorkImpl(): Int {
@@ -33,47 +76,75 @@ class SuggestionsWorker(appContext: Context, params: WorkerParameters) :
suggestionRepository.clear()
return 0
}
val rawResults = ArrayList<Manga>()
val allTags = historyRepository.getAllTags()
val blacklistTagRegex = appSettings.getSuggestionsTagsBlacklistRegex()
val allTags = historyRepository.getPopularTags(TAGS_LIMIT).filterNot {
blacklistTagRegex?.containsMatchIn(it.title) ?: false
}
if (allTags.isEmpty()) {
return 0
}
if (TAG in tags) { // not expedited
trySetForeground()
}
val tagsBySources = allTags.groupBy { x -> x.source }
for ((source, tags) in tagsBySources) {
val repo = MangaRepository(source)
tags.flatMapTo(rawResults) { tag ->
repo.getList(
offset = 0,
sortOrder = SortOrder.UPDATED,
tags = setOf(tag),
)
}
val dispatcher = Dispatchers.Default.limitedParallelism(MAX_PARALLELISM)
val rawResults = coroutineScope {
tagsBySources.flatMap { (source, tags) ->
val repo = MangaRepository(source)
tags.map { tag ->
async(dispatcher) {
repo.getList(
offset = 0,
sortOrder = SortOrder.UPDATED,
tags = setOf(tag),
)
}
}
}.awaitAll().flatten().asArrayList()
}
if (appSettings.isSuggestionsExcludeNsfw) {
rawResults.removeAll { it.isNsfw }
}
if (blacklistTagRegex != null) {
rawResults.removeAll {
it.tags.any { x -> blacklistTagRegex.containsMatchIn(x.title) }
}
}
if (rawResults.isEmpty()) {
return 0
}
val suggestions = rawResults.distinctBy { manga ->
manga.id
}.map { manga ->
val jointTags = manga.tags intersect allTags
MangaSuggestion(
manga = manga,
relevance = (jointTags.size / manga.tags.size.toDouble()).pow(2.0).toFloat(),
relevance = computeRelevance(manga.tags, allTags)
)
}.sortedBy { it.relevance }.take(LIMIT)
suggestionRepository.replace(suggestions)
return suggestions.size
}
@FloatRange(from = 0.0, to = 1.0)
private fun computeRelevance(mangaTags: Set<MangaTag>, allTags: List<MangaTag>): Float {
val maxWeight = (allTags.size + allTags.size + 1 - mangaTags.size) * mangaTags.size / 2.0
val weight = mangaTags.sumOf { tag ->
val index = allTags.indexOf(tag)
if (index < 0) 0 else allTags.size - index
}
return (weight / maxWeight).pow(2.0).toFloat()
}
companion object {
private const val TAG = "suggestions"
private const val TAG_ONESHOT = "suggestions_oneshot"
private const val LIMIT = 140
private const val TAGS_LIMIT = 20
private const val MAX_PARALLELISM = 4
private const val DATA_COUNT = "count"
private const val WORKER_CHANNEL_ID = "suggestion_worker"
private const val WORKER_NOTIFICATION_ID = 36
fun setup(context: Context) {
val constraints = Constraints.Builder()
@@ -96,6 +167,7 @@ class SuggestionsWorker(appContext: Context, params: WorkerParameters) :
val request = OneTimeWorkRequestBuilder<SuggestionsWorker>()
.setConstraints(constraints)
.addTag(TAG_ONESHOT)
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()
WorkManager.getInstance(context)
.enqueue(request)

View File

@@ -4,6 +4,8 @@ import androidx.room.withTransaction
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.TrackEntity
import org.koitharu.kotatsu.core.db.entity.TrackLogEntity
import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toTrackingLogItem
import org.koitharu.kotatsu.core.model.MangaTracking
import org.koitharu.kotatsu.core.model.TrackingLogItem
import org.koitharu.kotatsu.parsers.model.Manga
@@ -22,10 +24,10 @@ class TrackingRepository(
suspend fun getAllTracks(useFavourites: Boolean, useHistory: Boolean): List<MangaTracking> {
val mangaList = ArrayList<Manga>()
if (useFavourites) {
db.favouritesDao.findAllManga().mapTo(mangaList) { it.toManga() }
db.favouritesDao.findAllManga().mapTo(mangaList) { it.toManga(emptySet()) }
}
if (useHistory) {
db.historyDao.findAllManga().mapTo(mangaList) { it.toManga() }
db.historyDao.findAllManga().mapTo(mangaList) { it.toManga(emptySet()) }
}
val tracks = db.tracksDao.findAll().groupBy { it.mangaId }
return mangaList

View File

@@ -25,7 +25,9 @@ import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.measureHeight
import org.koitharu.kotatsu.utils.progress.Progress
class FeedFragment : BaseFragment<FragmentFeedBinding>(), PaginationScrollListener.Callback,
class FeedFragment :
BaseFragment<FragmentFeedBinding>(),
PaginationScrollListener.Callback,
MangaListListener {
private val viewModel by viewModel<FeedViewModel>()

View File

@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.tracker.ui
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -12,13 +11,13 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.model.TrackingLogItem
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.tracker.ui.model.toFeedItem
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.mapItems
class FeedViewModel(
private val repository: TrackingRepository
@@ -27,30 +26,34 @@ class FeedViewModel(
private val logList = MutableStateFlow<List<TrackingLogItem>?>(null)
private val hasNextPage = MutableStateFlow(false)
private var loadingJob: Job? = null
private val header = ListHeader(null, R.string.updates, null)
val isEmptyState = MutableLiveData(false)
val onFeedCleared = SingleLiveEvent<Unit>()
val content = combine(
logList.filterNotNull().mapItems {
it.toFeedItem()
},
logList.filterNotNull(),
hasNextPage
) { list, isHasNextPage ->
when {
list.isEmpty() -> listOf(
EmptyState(
icon = R.drawable.ic_feed,
textPrimary = R.string.text_empty_holder_primary,
textSecondary = R.string.text_feed_holder,
actionStringRes = 0,
buildList(list.size + 2) {
add(header)
if (list.isEmpty()) {
add(
EmptyState(
icon = R.drawable.ic_feed,
textPrimary = R.string.text_empty_holder_primary,
textSecondary = R.string.text_feed_holder,
actionStringRes = 0,
)
)
)
isHasNextPage -> list + LoadingFooter
else -> list
} else {
list.mapTo(this) { it.toFeedItem() }
if (isHasNextPage) {
add(LoadingFooter)
}
}
}
}.asLiveDataDistinct(
viewModelScope.coroutineContext + Dispatchers.Default,
listOf(LoadingState)
listOf(header, LoadingState)
)
init {
@@ -66,7 +69,6 @@ class FeedViewModel(
val list = repository.getTrackingLog(offset, 20)
if (!append) {
logList.value = list
isEmptyState.postValue(list.isEmpty())
} else if (list.isNotEmpty()) {
logList.value = logList.value?.plus(list) ?: list
}
@@ -80,7 +82,6 @@ class FeedViewModel(
lastJob?.cancelAndJoin()
repository.clearLogs()
logList.value = emptyList()
isEmptyState.postValue(true)
onFeedCleared.postCall(Unit)
}
}

View File

@@ -4,11 +4,10 @@ import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DiffUtil
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import kotlin.jvm.internal.Intrinsics
import org.koitharu.kotatsu.list.ui.adapter.*
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.tracker.ui.model.FeedItem
import kotlin.jvm.internal.Intrinsics
class FeedAdapter(
coil: ImageLoader,
@@ -24,6 +23,7 @@ class FeedAdapter(
.addDelegate(ITEM_TYPE_ERROR_FOOTER, errorFooterAD(listener))
.addDelegate(ITEM_TYPE_ERROR_STATE, errorStateListAD(listener))
.addDelegate(ITEM_TYPE_EMPTY, emptyStateListAD(listener))
.addDelegate(ITEM_TYPE_HEADER, listHeaderAD())
}
private class DiffCallback : DiffUtil.ItemCallback<ListModel>() {
@@ -32,10 +32,7 @@ class FeedAdapter(
oldItem is FeedItem && newItem is FeedItem -> {
oldItem.id == newItem.id
}
oldItem == LoadingFooter && newItem == LoadingFooter -> {
true
}
else -> false
else -> oldItem.javaClass == newItem.javaClass
}
override fun areContentsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
@@ -51,5 +48,6 @@ class FeedAdapter(
const val ITEM_TYPE_ERROR_STATE = 3
const val ITEM_TYPE_ERROR_FOOTER = 4
const val ITEM_TYPE_EMPTY = 5
const val ITEM_TYPE_HEADER = 6
}
}

View File

@@ -27,6 +27,7 @@ import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.PendingIntentCompat
import org.koitharu.kotatsu.utils.ext.referer
import org.koitharu.kotatsu.utils.ext.toBitmapOrNull
import org.koitharu.kotatsu.utils.ext.trySetForeground
import org.koitharu.kotatsu.utils.progress.Progress
import java.util.concurrent.TimeUnit
@@ -53,7 +54,9 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) :
if (tracks.isEmpty()) {
return Result.success()
}
setForeground(createForegroundInfo())
if (TAG in tags) { // not expedited
trySetForeground()
}
var success = 0
val workData = Data.Builder()
.putInt(DATA_TOTAL, tracks.size)
@@ -201,7 +204,7 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) :
}
}
private fun createForegroundInfo(): ForegroundInfo {
override suspend fun getForegroundInfo(): ForegroundInfo {
val title = applicationContext.getString(R.string.check_for_new_chapters)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
@@ -281,6 +284,7 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) :
val request = OneTimeWorkRequestBuilder<TrackWorker>()
.setConstraints(constraints)
.addTag(TAG_ONESHOT)
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()
WorkManager.getInstance(context)
.enqueue(request)

View File

@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.utils
import androidx.annotation.CheckResult
import androidx.annotation.WorkerThread
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
@@ -44,7 +43,6 @@ open class MutableZipFile(val file: File) {
dir.deleteRecursively()
}
@CheckResult
suspend fun flush(): Boolean = runInterruptible(Dispatchers.IO) {
val tempFile = File(file.path + ".tmp")
if (tempFile.exists()) {

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