Compare commits

..

71 Commits

Author SHA1 Message Date
bbd43b51e3 Increase version 2021-06-28 15:46:13 +03:00
a6a392c7bf Merge pull request #36 from ztimms73/fix-crashes
Fix possible crashes
2021-06-28 15:41:14 +03:00
08f92f9614 Fix possible crashes 2021-06-28 13:59:45 +03:00
873b41e4f9 Fix default languages selector 2021-06-26 15:15:53 +03:00
f0d4deffd7 Fix Mangaread parser 2021-06-26 14:49:13 +03:00
b6c50d59ed #35 Fix Mangaread parser 2021-06-24 07:27:07 +03:00
9fcc19ef7e Fix filename transliteration 2021-06-23 19:34:10 +03:00
b90ebdabf9 Fix storage dirs enumeration 2021-06-21 17:53:04 +03:00
e08a4cf1b2 Fix crash if title is empty 2021-06-21 17:50:16 +03:00
bd4efcf110 Remove old search option from menu 2021-06-21 17:32:23 +03:00
83a9570961 Prompt before search history clear 2021-06-20 16:04:12 +03:00
973a4073f0 Fix crash in history settings 2021-06-20 15:59:24 +03:00
867812b8e3 Update koin 2021-06-20 15:17:48 +03:00
cf7341b065 Update translations 2021-06-20 14:55:22 +03:00
8c2bc078e5 Manga languages onboarding 2021-06-17 19:45:36 +03:00
cd7d6d7674 New search suggestion UI 2021-06-03 19:15:57 +03:00
bc0c5ac71a Fix Anibel titles 2021-05-23 17:17:08 +03:00
91619cc259 NineManga sources #14 2021-05-23 16:58:45 +03:00
f0c9c61b49 Fix issues with spanish translation 2021-05-20 08:12:09 +03:00
d65158b7b9 Update Kotlin to 1.5 and dependencies 2021-05-20 08:09:35 +03:00
4e5de1e33e Merge pull request #32 from ztimms73/devel
Fix some problems from issue #23
2021-05-19 06:51:49 +03:00
b009a6423d Fix some problems from issue #23 2021-05-18 11:17:00 +03:00
467d0c8e18 Merge pull request #30 from ztimms73/sources
Fix Remanga sort, new manga source Anibel, UI changes
2021-05-17 19:48:52 +03:00
4d535cef41 Avoid searching for anime on Anibel 2021-05-15 22:51:45 +03:00
98147d0a81 Fix Anibel issues 2021-05-15 22:05:32 +03:00
323c1defaa Fix Remanga (closes #28) 2021-05-15 10:28:23 +03:00
Zakhar Timoshenko
60c5408ae8 Merge branch 'nv95:devel' into devel 2021-05-15 10:11:07 +03:00
51dc2ac046 Added the option to hide or not toolbar when scrolling 2021-05-15 08:56:03 +03:00
24d9a49420 Merge remote-tracking branch 'origin/devel' into devel 2021-05-14 22:45:07 +03:00
46891aa958 Minor UI changes 2021-05-14 22:43:14 +03:00
d1921193f0 Add Anibel source 2021-05-14 22:42:43 +03:00
a8c4c4045c Merge pull request #27 from ztimms73/devel
Belarusian translation and minor fixes in Russian
2021-05-14 07:16:06 +03:00
Zakhar Timoshenko
0559c13dc6 Fix 2021-05-13 20:29:01 +03:00
Xtimms
3fbec046ba Belarusian translation and minor fixes in Russian 2021-05-13 20:26:17 +03:00
4c8fa91af4 Merge pull request #26 from sguinetti/devel
Add spanish translation
2021-05-12 07:05:07 +03:00
sguinetti
1568c09fa2 Add spanish translation 2021-05-09 11:21:03 -05:00
0e74d6e017 Merge branch 'master' into devel 2021-05-03 18:18:55 +03:00
7690a29efb Add f-droid link to readme 2021-05-03 18:16:33 +03:00
b1d6f5debd Fix onApplyWindowInsets infinite call 2021-04-29 08:06:37 +03:00
fbb92005a1 Update database structure 2021-04-29 07:57:43 +03:00
5b9922d509 Remove unused code 2021-04-29 07:24:26 +03:00
49eebdf554 Specify LazyThreadSafetyMode for inject and viewModel delegates 2021-04-27 19:52:20 +03:00
2da941d550 Update dependencies 2021-04-27 19:41:41 +03:00
5a8d7531bf Ui fixes 2021-04-11 12:20:12 +03:00
4d1f5e22d3 #21 Fix cloudflare passing 2021-04-11 11:33:04 +03:00
012416c881 Improve password protection 2021-04-11 11:24:50 +03:00
0f48ad07a3 Support no-fullheight pages in webtoon mode 2021-04-04 11:18:12 +03:00
64752da948 Optimize global search 2021-04-02 15:26:27 +03:00
95148a1071 Add distributionSha256Sum for gradle wrapper 2021-03-30 07:49:10 +03:00
d9d0656ef4 Update dependencies and gradle 2021-03-29 20:00:05 +03:00
b17d8efa5c Update Koin to 3.0 2021-03-29 19:18:08 +03:00
b07fcf5842 Use static version code 2021-03-28 11:34:57 +03:00
f669a1ca0f Update version name 2021-03-25 19:38:50 +02:00
5a921ea862 Update readme 2021-03-25 19:30:28 +02:00
810efde0b0 Add metadata 2021-03-25 19:17:46 +02:00
03510a1f19 Update dependencies 2021-03-25 07:47:43 +02:00
049f32d2f0 Fix smooth page switching 2021-03-21 18:33:13 +02:00
11a9db3cc2 Fix "Unsupported image format" error 2021-03-21 18:18:57 +02:00
57dd5743f0 Fix tabs on landscape 2021-03-21 18:16:38 +02:00
5f37e76c85 Update dialogs 2021-03-19 21:11:36 +02:00
fc51d49505 Fix lists padding 2021-03-19 20:42:14 +02:00
3dde254452 Refresh tabs style 2021-03-19 20:14:08 +02:00
aa21dd9721 Fixes 2021-03-19 19:38:29 +02:00
71f5ee8cb1 Support for multiple manga branches (translations, etc) 2021-03-08 10:30:15 +02:00
40f27ae634 Fix page saving 2021-02-25 20:10:38 +02:00
0dfba47d85 Misc small fixes 2021-02-19 20:21:44 +02:00
69e44b10e9 Fix tags on details screen 2021-02-18 20:25:01 +02:00
4cd0cb04a3 Fix reader state restoration 2021-02-16 20:22:18 +02:00
d9d5595bde Ellipsize chapters in feed 2021-02-16 20:03:25 +02:00
ed70ca4e18 Information toast in reader 2021-02-11 19:29:17 +02:00
a371bb6514 Fix activity leak on Android Q 2021-02-08 20:29:32 +02:00
221 changed files with 3825 additions and 1064 deletions

1
.gitignore vendored
View File

@@ -4,6 +4,7 @@
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/misc.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml

View File

@@ -23,6 +23,7 @@
</option>
</AndroidXmlCodeStyleSettings>
<JetCodeStyleSettings>
<option name="ALLOW_TRAILING_COMMA" value="true" />
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="CMake">

17
.idea/deploymentTargetDropDown.xml generated Normal file
View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetDropDown">
<targetSelectedWithDropDown>
<Target>
<type value="QUICK_BOOT_TARGET" />
<deviceKey>
<Key>
<type value="VIRTUAL_DEVICE_PATH" />
<value value="$USER_HOME$/.android/avd/Pixel_API_S.avd" />
</Key>
</deviceKey>
</Target>
</targetSelectedWithDropDown>
<timeTargetWasSelectedWithDropDown value="2021-02-19T19:02:37.198775Z" />
</component>
</project>

View File

@@ -31,5 +31,10 @@
<option name="name" value="maven2" />
<option name="url" value="https://dl.bintray.com/kotlin/kotlin-eap" />
</remote-repository>
<remote-repository>
<option name="id" value="MavenRepo" />
<option name="name" value="MavenRepo" />
<option name="url" value="https://repo.maven.apache.org/maven2/" />
</remote-repository>
</component>
</project>

9
.idea/misc.xml generated
View File

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" default="true" project-jdk-name="1.8" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

View File

@@ -6,29 +6,34 @@ Kotatsu is a free and open source manga reader for Android.
### Download
Latest release: [get here](https://github.com/nv95/Kotatsu/releases/latest)
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
alt="Get it on F-Droid"
height="80">](https://f-droid.org/packages/org.koitharu.kotatsu)
Legacy build (Android 4.1+): [available here](https://github.com/nv95/Kotatsu/releases/tag/v0.4-legacy)
Download APK from Github Releases:
- [Latest release](https://github.com/nv95/Kotatsu/releases/latest)
- [Legacy build](https://github.com/nv95/Kotatsu/releases/tag/v0.4-legacy) (with Android 4.1+ support)
### Main Features
* Online manga catalogues
* Search manga by name and genre
* Reading history
* Favourites with custom categories
* Saving manga and reading it offline
* Tablet-optimized modern UI
* Reading third-party comics from CBZ
* Favourites organized by user-defined categories
* Downloading manga and reading it offline. Third-party CBZ archives also supported
* Tablet-optimized material design UI
* Standard and Webtoon-optimized reader
* Notifications about new chapters
* Updates feed
* Global search
* Notifications about new chapters with updates feed
### Screenshots
| ![Screenshot_20200226-210337](https://user-images.githubusercontent.com/8948226/80315102-3478db00-87fe-11ea-9ce8-4bbd1c254b2b.png) | ![Screenshot_20200226-210310](https://user-images.githubusercontent.com/8948226/80315110-3f337000-87fe-11ea-95df-944c196b6667.png) | ![Screenshot_20200226-210232](https://user-images.githubusercontent.com/8948226/80315121-49ee0500-87fe-11ea-8d9b-537a041bbf2f.png) |
| ![Screenshot_20200226-210337](https://github.com/nv95/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/1.png) | ![](https://github.com/nv95/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/2.png) | ![Screenshot_20200226-210232](https://github.com/nv95/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/3.png) |
|---|---|---|
| ![Screenshot_20200226-210405](https://user-images.githubusercontent.com/8948226/80315130-55d9c700-87fe-11ea-8350-2c8452906eb7.png) | ![Screenshot_20200226-210151](https://user-images.githubusercontent.com/8948226/80315135-612cf280-87fe-11ea-984c-aa18567d5bbc.png) | ![Screenshot_20200226-210223](https://user-images.githubusercontent.com/8948226/80315146-6be78780-87fe-11ea-8439-ca1ca578172b.png) |
| ![Screenshot_20200226-210405](https://github.com/nv95/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/4.png) | ![Screenshot_20200226-210151](https://github.com/nv95/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/5.png) | ![Screenshot_20200226-210223](https://github.com/nv95/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/6.png) |
| ![](https://github.com/nv95/Kotatsu/raw/devel/metadata/en-US/images/tenInchScreenshots/1.png) | ![](https://github.com/nv95/Kotatsu/raw/devel/metadata/en-US/images/tenInchScreenshots/2.png) |
|---|---|
### License
[![GNU GPLv3 Image](https://www.gnu.org/graphics/gplv3-127x51.png)](http://www.gnu.org/licenses/gpl-3.0.en.html)

View File

@@ -5,8 +5,6 @@ plugins {
id 'kotlin-parcelize'
}
def gitCommits = 'git rev-list --count HEAD'.execute([], rootDir).text.trim().toInteger()
android {
compileSdkVersion 30
buildToolsVersion '30.0.3'
@@ -15,8 +13,8 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdkVersion 21
targetSdkVersion 30
versionCode gitCommits
versionName '1.0-rc1'
versionCode 366
versionName '1.1.1'
kapt {
arguments {
@@ -56,54 +54,53 @@ tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all {
jvmTarget = JavaVersion.VERSION_1_8.toString()
freeCompilerArgs += [
'-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
'-Xopt-in=kotlinx.coroutines.FlowPreview',
'-Xopt-in=org.koin.core.component.KoinApiExtension'
]
}
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.2'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.2'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0'
implementation 'androidx.core:core-ktx:1.5.0-beta01'
implementation 'androidx.activity:activity-ktx:1.2.0-rc01'
implementation 'androidx.fragment:fragment-ktx:1.3.0-rc02'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.0-rc01'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.0-rc01'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.3.0-rc01'
implementation 'androidx.lifecycle:lifecycle-service:2.3.0-rc01'
implementation 'androidx.lifecycle:lifecycle-process:2.3.0-rc01'
implementation 'androidx.core:core-ktx:1.5.0'
implementation 'androidx.activity:activity-ktx:1.2.3'
implementation 'androidx.fragment:fragment-ktx:1.3.5'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.3.1'
implementation 'androidx.lifecycle:lifecycle-service:2.3.1'
implementation 'androidx.lifecycle:lifecycle-process:2.3.1'
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.2.0-beta01'
implementation 'androidx.recyclerview:recyclerview:1.2.1'
implementation 'androidx.viewpager2:viewpager2:1.1.0-alpha01'
implementation 'androidx.preference:preference-ktx:1.1.1'
implementation 'androidx.work:work-runtime-ktx:2.5.0'
implementation 'com.google.android.material:material:1.3.0-rc01'
implementation 'com.google.android.material:material:1.3.0'
//noinspection LifecycleAnnotationProcessorWithJava8
kapt 'androidx.lifecycle:lifecycle-compiler:2.3.0-rc01'
kapt 'androidx.lifecycle:lifecycle-compiler:2.3.1'
implementation 'androidx.room:room-runtime:2.2.6'
implementation 'androidx.room:room-ktx:2.2.6'
kapt 'androidx.room:room-compiler:2.2.6'
implementation 'androidx.room:room-runtime:2.3.0'
implementation 'androidx.room:room-ktx:2.3.0'
kapt 'androidx.room:room-compiler:2.3.0'
implementation 'com.squareup.okhttp3:okhttp:4.9.0'
implementation 'com.squareup.okhttp3:okhttp:4.9.1'
implementation 'com.squareup.okio:okio:2.10.0'
implementation 'org.jsoup:jsoup:1.13.1'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.0'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.0'
implementation 'org.koin:koin-android:2.2.2'
implementation 'org.koin:koin-androidx-viewmodel:2.2.2'
implementation 'io.coil-kt:coil-base:1.1.1'
implementation 'io.insert-koin:koin-android:3.1.0'
implementation 'io.coil-kt:coil-base:1.2.2'
implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
implementation 'com.tomclaw.cache:cache:1.0'
implementation 'com.github.solkin:disk-lru-cache:1.2'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.6'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.7'
testImplementation 'junit:junit:4.13.1'
testImplementation 'org.json:json:20201115'
testImplementation 'org.koin:koin-test:2.2.2'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.json:json:20210307'
testImplementation 'io.insert-koin:koin-test-junit4:3.1.0'
}

View File

@@ -78,6 +78,10 @@
android:label="@string/search" />
<activity
android:name="org.koitharu.kotatsu.main.ui.protect.ProtectActivity"
android:noHistory="true"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".settings.protect.ProtectSetupActivity"
android:windowSoftInputMode="adjustResize" />
<service
@@ -106,6 +110,7 @@
<receiver
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetProvider"
android:exported="true"
android:label="@string/manga_shelf">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
@@ -116,6 +121,7 @@
</receiver>
<receiver
android:name="org.koitharu.kotatsu.widget.recent.RecentWidgetProvider"
android:exported="true"
android:label="@string/recent_manga">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
@@ -125,6 +131,13 @@
android:resource="@xml/widget_recent" />
</receiver>
<meta-data
android:name="android.webkit.WebView.EnableSafeBrowsing"
android:value="false" />
<meta-data
android:name="android.webkit.WebView.MetricsOptOut"
android:value="true" />
</application>
</manifest>

View File

@@ -15,14 +15,13 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.AppCrashHandler
import org.koitharu.kotatsu.core.ui.uiModule
import org.koitharu.kotatsu.details.detailsModule
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.favouritesModule
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.history.historyModule
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.local.localModule
import org.koitharu.kotatsu.main.mainModule
import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
import org.koitharu.kotatsu.reader.readerModule
import org.koitharu.kotatsu.remotelist.remoteListModule
import org.koitharu.kotatsu.search.searchModule
@@ -36,28 +35,15 @@ class KotatsuApp : Application() {
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectAll()
.penaltyLog()
.build()
)
StrictMode.setVmPolicy(
StrictMode.VmPolicy.Builder()
.detectAll()
.setClassInstanceLimit(LocalMangaRepository::class.java, 1)
.setClassInstanceLimit(PagesCache::class.java, 1)
.setClassInstanceLimit(MangaLoaderContext::class.java, 1)
.penaltyLog()
.build()
)
enableStrictMode()
}
initKoin()
Thread.setDefaultUncaughtExceptionHandler(AppCrashHandler(applicationContext))
AppCompatDelegate.setDefaultNightMode(get<AppSettings>().theme)
registerActivityLifecycleCallbacks(get<AppProtectHelper>())
val widgetUpdater = WidgetUpdater(applicationContext)
FavouritesRepository.subscribe(widgetUpdater)
HistoryRepository.subscribe(widgetUpdater)
widgetUpdater.subscribeToFavourites(get())
widgetUpdater.subscribeToHistory(get())
}
private fun initKoin() {
@@ -83,4 +69,22 @@ class KotatsuApp : Application() {
)
}
}
private fun enableStrictMode() {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectAll()
.penaltyLog()
.build()
)
StrictMode.setVmPolicy(
StrictMode.VmPolicy.Builder()
.detectAll()
.setClassInstanceLimit(LocalMangaRepository::class.java, 1)
.setClassInstanceLimit(PagesCache::class.java, 1)
.setClassInstanceLimit(MangaLoaderContext::class.java, 1)
.penaltyLog()
.build()
)
}
}

View File

@@ -12,10 +12,13 @@ open class MangaLoaderContext(
private val cookieJar: CookieJar
) : KoinComponent {
suspend fun httpGet(url: String): Response {
suspend fun httpGet(url: String, headers: Headers? = null): Response {
val request = Request.Builder()
.get()
.url(url)
if (headers != null) {
request.headers(headers)
}
return okHttp.newCall(request.build()).await()
}

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.base.ui
import android.os.Build
import android.os.Bundle
import android.view.KeyEvent
import android.view.MenuItem
@@ -14,6 +15,8 @@ import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets
import androidx.core.view.*
import androidx.viewbinding.ViewBinding
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.appbar.AppBarLayout.LayoutParams.*
import org.koin.android.ext.android.get
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
@@ -26,11 +29,12 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(), OnApplyWindo
protected lateinit var binding: B
private set
protected val exceptionResolver by lazy(LazyThreadSafetyMode.NONE) {
ExceptionResolver(this, supportFragmentManager)
}
private var lastInsets: Insets = Insets.NONE
override fun onCreate(savedInstanceState: Bundle?) {
if (get<AppSettings>().isAmoledTheme) {
setTheme(R.style.AppTheme_Amoled)
@@ -55,11 +59,23 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(), OnApplyWindo
this.binding = binding
super.setContentView(binding.root)
(binding.root.findViewById<View>(R.id.toolbar) as? Toolbar)?.let(this::setSupportActionBar)
val params = (binding.root.findViewById<View>(R.id.toolbar) as? Toolbar)?.layoutParams as? AppBarLayout.LayoutParams
ViewCompat.setOnApplyWindowInsetsListener(binding.root, this)
if (get<AppSettings>().isToolbarHideWhenScrolling) {
params?.scrollFlags = SCROLL_FLAG_SCROLL or SCROLL_FLAG_ENTER_ALWAYS
} else {
params?.scrollFlags = SCROLL_FLAG_NO_SCROLL
}
}
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
onWindowInsetsChanged(insets.getInsets(WindowInsetsCompat.Type.systemBars()))
val baseInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime())
val newInsets = Insets.max(baseInsets, imeInsets)
if (newInsets != lastInsets) {
onWindowInsetsChanged(newInsets)
lastInsets = newInsets
}
return insets
}
@@ -97,4 +113,16 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(), OnApplyWindo
super.onSupportActionModeFinished(mode)
window?.statusBarColor = getThemeColor(android.R.attr.statusBarColor)
}
override fun onBackPressed() {
if ( // https://issuetracker.google.com/issues/139738913
Build.VERSION.SDK_INT == Build.VERSION_CODES.Q &&
isTaskRoot &&
supportFragmentManager.backStackEntryCount == 0
) {
finishAfterTransition()
} else {
super.onBackPressed()
}
}
}

View File

@@ -24,6 +24,8 @@ abstract class BaseFragment<B : ViewBinding> : Fragment(), OnApplyWindowInsetsLi
ExceptionResolver(viewLifecycleOwner, childFragmentManager)
}
private var lastInsets: Insets = Insets.NONE
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
@@ -54,7 +56,11 @@ abstract class BaseFragment<B : ViewBinding> : Fragment(), OnApplyWindowInsetsLi
}
override fun onApplyWindowInsets(v: View?, insets: WindowInsetsCompat): WindowInsetsCompat {
onWindowInsetsChanged(insets.getInsets(WindowInsetsCompat.Type.systemBars()))
val newInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars())
if (newInsets != lastInsets) {
onWindowInsetsChanged(newInsets)
lastInsets = newInsets
}
return insets
}

View File

@@ -14,7 +14,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
PreferenceFragmentCompat(), OnApplyWindowInsetsListener {
protected val settings by inject<AppSettings>()
protected val settings by inject<AppSettings>(mode = LazyThreadSafetyMode.NONE)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

View File

@@ -7,6 +7,7 @@ import android.view.ViewGroup
import android.widget.BaseAdapter
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemStorageBinding
import org.koitharu.kotatsu.local.domain.LocalMangaRepository

View File

@@ -6,6 +6,7 @@ import android.text.InputFilter
import android.view.LayoutInflater
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.databinding.DialogInputBinding
class TextInputDialog private constructor(

View File

@@ -0,0 +1,96 @@
package org.koitharu.kotatsu.base.ui.widgets
import android.content.Context
import android.util.AttributeSet
import android.view.View.OnClickListener
import androidx.annotation.DrawableRes
import androidx.core.view.children
import com.google.android.material.R
import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipGroup
import org.koitharu.kotatsu.utils.ext.getThemeColor
class ChipsView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = R.attr.chipGroupStyle
) : ChipGroup(context, attrs, defStyleAttr) {
private var isLayoutSuppressedCompat = false
private var isLayoutCalledOnSuppressed = false
private var chipOnClickListener = OnClickListener {
onChipClickListener?.onChipClick(it as Chip, it.tag)
}
var onChipClickListener: OnChipClickListener? = null
set(value) {
field = value
val isChipClickable = value != null
children.forEach { it.isClickable = isChipClickable }
}
override fun requestLayout() {
if (isLayoutSuppressedCompat) {
isLayoutCalledOnSuppressed = true
} else {
super.requestLayout()
}
}
fun setChips(items: List<ChipModel>) {
suppressLayoutCompat(true)
try {
for ((i, model) in items.withIndex()) {
val chip = getChildAt(i) as Chip? ?: addChip()
bindChip(chip, model)
}
for (i in items.size until childCount) {
removeViewAt(i)
}
} finally {
suppressLayoutCompat(false)
}
}
private fun bindChip(chip: Chip, model: ChipModel) {
chip.text = model.title
if (model.icon == 0) {
chip.isChipIconVisible = false
} else {
chip.isCheckedIconVisible = true
chip.setChipIconResource(model.icon)
}
chip.tag = model.data
}
private fun addChip(): Chip {
val chip = Chip(context)
chip.setTextColor(context.getThemeColor(android.R.attr.textColorPrimary))
chip.isCloseIconVisible = false
chip.setEnsureMinTouchTargetSize(false)
chip.setOnClickListener(chipOnClickListener)
chip.isClickable = onChipClickListener != null
addView(chip)
return chip
}
private fun suppressLayoutCompat(suppress: Boolean) {
isLayoutSuppressedCompat = suppress
if (!suppress) {
if (isLayoutCalledOnSuppressed) {
requestLayout()
isLayoutCalledOnSuppressed = false
}
}
}
data class ChipModel(
@DrawableRes val icon: Int,
val title: CharSequence,
val data: Any? = null
)
fun interface OnChipClickListener {
fun onChipClick(chip: Chip, data: Any?)
}
}

View File

@@ -41,7 +41,7 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
}
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.opt_browser, menu)
return super.onCreateOptionsMenu(menu)
}

View File

@@ -11,7 +11,7 @@ import org.koitharu.kotatsu.core.network.WebViewClientCompat
class BrowserClient(private val callback: BrowserCallback) : WebViewClientCompat(), KoinComponent {
private val okHttp by inject<OkHttpClient>()
private val okHttp by inject<OkHttpClient>(mode = LazyThreadSafetyMode.SYNCHRONIZED)
override fun onPageFinished(webView: WebView, url: String) {
super.onPageFinished(webView, url)

View File

@@ -12,9 +12,7 @@ class CloudFlareClient(
private val targetUrl: String
) : WebViewClientCompat() {
init {
cookieJar.remove(targetUrl, CF_UID, CF_CLEARANCE)
}
private val oldClearance = getCookieValue(CF_CLEARANCE)
override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
@@ -32,16 +30,19 @@ class CloudFlareClient(
}
private fun checkClearance() {
val cookies = cookieJar.loadForRequest(targetUrl.toHttpUrl())
if (cookies.any { it.name == CF_CLEARANCE }) {
val clearance = getCookieValue(CF_CLEARANCE)
if (clearance != null && clearance != oldClearance) {
callback.onCheckPassed()
}
}
private fun getCookieValue(name: String): String? {
return cookieJar.loadForRequest(targetUrl.toHttpUrl())
.find { it.name == name }?.value
}
private companion object {
const val CF_UID = "__cfduid"
const val CF_CLEARANCE = "cf_clearance"
}
}

View File

@@ -83,6 +83,7 @@ class BackupRepository(private val db: MangaDatabase) {
jo.put("url", url)
jo.put("public_url", publicUrl)
jo.put("rating", rating)
jo.put("nsfw", isNsfw)
jo.put("cover_url", coverUrl)
jo.put("large_cover_url", largeCoverUrl)
jo.put("state", state)

View File

@@ -8,6 +8,7 @@ import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.utils.ext.getBooleanOrDefault
import org.koitharu.kotatsu.utils.ext.getStringOrNull
import org.koitharu.kotatsu.utils.ext.iterator
import org.koitharu.kotatsu.utils.ext.map
@@ -72,6 +73,7 @@ class RestoreRepository(private val db: MangaDatabase) {
url = json.getString("url"),
publicUrl = json.getStringOrNull("public_url").orEmpty(),
rating = json.getDouble("rating").toFloat(),
isNsfw = json.getBooleanOrDefault("nsfw", false),
coverUrl = json.getString("cover_url"),
largeCoverUrl = json.getStringOrNull("large_cover_url"),
state = json.getStringOrNull("state"),

View File

@@ -18,7 +18,8 @@ val databaseModule
Migration3To4(),
Migration4To5(),
Migration5To6(),
Migration6To7()
Migration6To7(),
Migration7To8(),
).addCallback(
DatabasePrePopulateCallback(androidContext().resources)
).build()

View File

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

View File

@@ -13,6 +13,14 @@ abstract class MangaDao {
@Query("SELECT * FROM manga WHERE manga_id = :id")
abstract suspend fun find(id: Long): MangaWithTags?
@Transaction
@Query("SELECT * FROM manga WHERE title LIKE :query OR alt_title LIKE :query LIMIT :limit")
abstract suspend fun searchByTitle(query: String, limit: Int): List<MangaWithTags>
@Transaction
@Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND source = :source LIMIT :limit")
abstract suspend fun searchByTitle(query: String, source: String, limit: Int): List<MangaWithTags>
@Insert(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun insert(manga: MangaEntity): Long

View File

@@ -13,14 +13,15 @@ data class MangaEntity(
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id") val id: Long,
@ColumnInfo(name = "title") val title: String,
@ColumnInfo(name = "alt_title") val altTitle: String? = null,
@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 = Manga.NO_RATING, //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? = null,
@ColumnInfo(name = "state") val state: String? = null,
@ColumnInfo(name = "author") val author: String? = null,
@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
) {
@@ -30,6 +31,7 @@ data class MangaEntity(
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,
@@ -50,6 +52,7 @@ data class MangaEntity(
coverUrl = manga.coverUrl,
altTitle = manga.altTitle,
rating = manga.rating,
isNsfw = manga.isNsfw,
state = manga.state?.name,
title = manga.title,
author = manga.author

View File

@@ -0,0 +1,24 @@
package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
@Entity(
tableName = "suggestions",
foreignKeys = [
ForeignKey(
entity = MangaEntity::class,
parentColumns = ["manga_id"],
childColumns = ["manga_id"],
onDelete = ForeignKey.CASCADE
)
]
)
data class SuggestionEntity(
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
@ColumnInfo(name = "relevance") val relevance: Float,
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(),
)

View File

@@ -0,0 +1,13 @@
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration7To8 : Migration(7, 8) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE manga ADD COLUMN nsfw INTEGER NOT NULL DEFAULT 0")
database.execSQL("CREATE TABLE IF NOT EXISTS suggestions (manga_id INTEGER NOT NULL, relevance REAL NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY(manga_id), FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )")
database.execSQL("CREATE INDEX IF NOT EXISTS index_suggestions_manga_id ON suggestions (manga_id)")
}
}

View File

@@ -11,6 +11,7 @@ data class Manga(
val url: String, // relative url for internal use
val publicUrl: String,
val rating: Float = NO_RATING, //normalized value [0..1] or -1
val isNsfw: Boolean = false,
val coverUrl: String,
val largeCoverUrl: String? = null,
val description: String? = null, //HTML

View File

@@ -9,5 +9,6 @@ data class MangaChapter(
val name: String,
val number: Int,
val url: String,
val branch: String? = null,
val source: MangaSource
) : Parcelable

View File

@@ -30,7 +30,16 @@ enum class MangaSource(
// NUDEMOON("Nude-Moon", "ru", NudeMoonRepository::class.java),
MANGAREAD("MangaRead", "en", MangareadRepository::class.java),
REMANGA("Remanga", "ru", RemangaRepository::class.java),
HENTAILIB("HentaiLib", "ru", HentaiLibRepository::class.java);
HENTAILIB("HentaiLib", "ru", HentaiLibRepository::class.java),
ANIBEL("Anibel", "be", AnibelRepository::class.java),
NINEMANGA_EN("NineManga English", "en", NineMangaRepository.English::class.java),
NINEMANGA_ES("NineManga Español", "es", NineMangaRepository.Spanish::class.java),
NINEMANGA_RU("NineManga Русский", "ru", NineMangaRepository.Russian::class.java),
NINEMANGA_DE("NineManga Deutsch", "de", NineMangaRepository.Deutsch::class.java),
NINEMANGA_IT("NineManga Italiano", "it", NineMangaRepository.Italiano::class.java),
NINEMANGA_BR("NineManga Brasil", "pt", NineMangaRepository.Brazil::class.java),
NINEMANGA_FR("NineManga Français", "fr", NineMangaRepository.Francais::class.java),
;
@get:Throws(NoBeanDefFoundException::class)
@Deprecated("")

View File

@@ -28,19 +28,6 @@ class AndroidCookieJar : CookieJar {
}
}
fun remove(url: String, vararg names: String) {
val cookies = cookieManager.getCookie(url) ?: return
val newCookies = cookies.split(";")
.filterNot { cookie ->
names.any { cookie.startsWith("$it=") }
}.joinToString(";")
cookieManager.setCookie(url, newCookies)
}
fun clearAsync() {
cookieManager.removeAllCookies(null)
}
suspend fun clear() = suspendCoroutine<Boolean> { continuation ->
cookieManager.removeAllCookies(continuation::resume)
}

View File

@@ -0,0 +1,59 @@
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
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)
}
private companion object {
const val TAG = "CURL"
}
}

View File

@@ -6,6 +6,7 @@ import org.koin.android.ext.koin.androidContext
import org.koin.core.qualifier.named
import org.koin.dsl.bind
import org.koin.dsl.module
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.utils.CacheUtils
import java.util.concurrent.TimeUnit
@@ -22,6 +23,9 @@ val networkModule
cache(get(named(CacheUtils.QUALIFIER_HTTP)))
addInterceptor(UserAgentInterceptor())
addInterceptor(CloudFlareInterceptor())
if (BuildConfig.DEBUG) {
addNetworkInterceptor(CurlLoggingInterceptor())
}
}.build()
}
}

View File

@@ -35,6 +35,7 @@ class ShortcutsRepository(
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) return
val manager = context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager
val shortcuts = historyRepository.getList(0, manager.maxShortcutCountPerActivity)
.filter { x -> x.title.isNotEmpty() }
.map { buildShortcutInfo(it).build().toShortcutInfo() }
manager.dynamicShortcuts = shortcuts
}

View File

@@ -24,4 +24,12 @@ val parserModule
factory<MangaRepository>(named(MangaSource.MANGAREAD)) { MangareadRepository(get()) }
factory<MangaRepository>(named(MangaSource.REMANGA)) { RemangaRepository(get()) }
factory<MangaRepository>(named(MangaSource.HENTAILIB)) { HentaiLibRepository(get()) }
factory<MangaRepository>(named(MangaSource.ANIBEL)) { AnibelRepository(get()) }
factory<MangaRepository>(named(MangaSource.NINEMANGA_EN)) { NineMangaRepository.English(get()) }
factory<MangaRepository>(named(MangaSource.NINEMANGA_BR)) { NineMangaRepository.Brazil(get()) }
factory<MangaRepository>(named(MangaSource.NINEMANGA_DE)) { NineMangaRepository.Deutsch(get()) }
factory<MangaRepository>(named(MangaSource.NINEMANGA_ES)) { NineMangaRepository.Spanish(get()) }
factory<MangaRepository>(named(MangaSource.NINEMANGA_RU)) { NineMangaRepository.Russian(get()) }
factory<MangaRepository>(named(MangaSource.NINEMANGA_IT)) { NineMangaRepository.Italiano(get()) }
factory<MangaRepository>(named(MangaSource.NINEMANGA_FR)) { NineMangaRepository.Francais(get()) }
}

View File

@@ -0,0 +1,167 @@
package org.koitharu.kotatsu.core.parser.site
import org.koitharu.kotatsu.base.domain.MangaLoaderContext
import org.koitharu.kotatsu.core.exceptions.ParseException
import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.utils.ext.*
import java.util.*
class AnibelRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) {
override val source = MangaSource.ANIBEL
override val defaultDomain = "anibel.net"
override val sortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.NEWEST
)
override suspend fun getList(
offset: Int,
query: String?,
sortOrder: SortOrder?,
tag: MangaTag?
): List<Manga> {
if (!query.isNullOrEmpty()) {
return if (offset == 0) search(query) else emptyList()
}
val page = (offset / 12f).toIntUp().inc()
val link = when {
tag != null -> "/manga?genre[]=${tag.key}&page=$page".withDomain()
else -> "/manga?page=$page".withDomain()
}
val doc = loaderContext.httpGet(link).parseHtml()
val root = doc.body().select("div.manga-block") ?: throw ParseException("Cannot find root")
val items = root.select("div.anime-card")
return items.mapNotNull { card ->
val href = card.selectFirst("a").attr("href")
val status = card.select("tr")[2].text()
val fullTitle = card.selectFirst("h1.anime-card-title").text()
.substringBeforeLast('[')
val titleParts = fullTitle.splitTwoParts('/')
Manga(
id = generateUid(href),
title = titleParts?.first?.trim() ?: fullTitle,
coverUrl = card.selectFirst("img").attr("data-src").withDomain(),
altTitle = titleParts?.second?.trim(),
author = null,
rating = Manga.NO_RATING,
url = href,
publicUrl = href.withDomain(),
tags = card.select("p.tupe.tag")?.select("a")?.mapNotNullToSet tags@{ x ->
MangaTag(
title = x.text(),
key = x.attr("href")?.substringAfterLast("=") ?: return@tags null,
source = source
)
}.orEmpty(),
state = when (status) {
"выпускаецца" -> MangaState.ONGOING
"завершанае" -> MangaState.FINISHED
else -> null
},
source = source
)
}
}
override suspend fun getDetails(manga: Manga): Manga {
val doc = loaderContext.httpGet(manga.publicUrl).parseHtml()
val root = doc.body().select("div.container") ?: throw ParseException("Cannot find root")
return manga.copy(
description = root.select("div.manga-block.grid-12")[2].select("p").text(),
chapters = root.select("ul.series").flatMap { table ->
table.select("li")
}.map { it.selectFirst("a") }.mapIndexedNotNull { i, a ->
val href = a.select("a").first().attr("href").toRelativeUrl(getDomain())
MangaChapter(
id = generateUid(href),
name = a.select("a").first().text(),
number = i + 1,
url = href,
source = source
)
}
)
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val fullUrl = chapter.url.withDomain()
val doc = loaderContext.httpGet(fullUrl).parseHtml()
val scripts = doc.select("script")
for (script in scripts) {
val data = script.html()
val pos = data.indexOf("dataSource")
if (pos == -1) {
continue
}
val json = data.substring(pos).substringAfter('[').substringBefore(']')
val domain = getDomain()
return json.split(",").mapNotNull {
it.trim()
.removeSurrounding('"', '\'')
.toRelativeUrl(domain)
.takeUnless(String::isBlank)
}.map { url ->
MangaPage(
id = generateUid(url),
url = url,
referer = fullUrl,
source = source
)
}
}
throw ParseException("Pages list not found at ${chapter.url.withDomain()}")
}
override suspend fun getTags(): Set<MangaTag> {
val doc = loaderContext.httpGet("https://${getDomain()}/manga").parseHtml()
val root = doc.body().select("div#tabs-genres").select("ul#list.ul-three-colums")
return root.select("p.menu-tags.tupe").mapToSet { a ->
MangaTag(
title = a.select("a").text().capitalize(Locale.ROOT),
key = a.select("a").attr("data-name"),
source = source
)
}
}
private suspend fun search(query: String): List<Manga> {
val domain = getDomain()
val doc = loaderContext.httpGet("https://$domain/search?q=$query").parseHtml()
val root = doc.body().select("div.manga-block").select("article.tab-2") ?: throw ParseException("Cannot find root")
val items = root.select("div.anime-card")
return items.mapNotNull { card ->
val href = card.select("a").attr("href")
val status = card.select("tr")[2].text()
val fullTitle = card.selectFirst("h1.anime-card-title").text()
.substringBeforeLast('[')
val titleParts = fullTitle.splitTwoParts('/')
Manga(
id = generateUid(href),
title = titleParts?.first?.trim() ?: fullTitle,
coverUrl = card.selectFirst("img").attr("src").withDomain(),
altTitle = titleParts?.second?.trim(),
author = null,
rating = Manga.NO_RATING,
url = href,
publicUrl = href.withDomain(),
tags = card.select("p.tupe.tag")?.select("a")?.mapNotNullToSet tags@{ x ->
MangaTag(
title = x.text(),
key = x.attr("href")?.substringAfterLast("=") ?: return@tags null,
source = source
)
}.orEmpty(),
state = when (status) {
"выпускаецца" -> MangaState.ONGOING
"завершанае" -> MangaState.FINISHED
else -> null
},
source = source
)
}
}
}

View File

@@ -90,6 +90,8 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
chapters = ArrayList(total)
for (i in 0 until total) {
val item = list.getJSONObject(i)
val chapterId = item.getLong("chapter_id")
val branchName = item.getStringOrNull("username")
val url = buildString {
append(manga.url)
append("/v")
@@ -106,9 +108,10 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
}
chapters.add(
MangaChapter(
id = generateUid(url),
id = generateUid(chapterId),
url = url,
source = source,
branch = branchName,
number = total - i,
name = name
)

View File

@@ -49,18 +49,17 @@ class MangareadRepository(
id = generateUid(href),
url = href,
publicUrl = href.inContextOf(div),
coverUrl = div.selectFirst("img").attr("data-srcset")
.split(',').firstOrNull()?.substringBeforeLast(' ').orEmpty(),
coverUrl = div.selectFirst("img").absUrl("src"),
title = summary.selectFirst("h3").text(),
rating = div.selectFirst("span.total_votes")?.ownText()
?.toFloatOrNull()?.div(5f) ?: -1f,
tags = summary.selectFirst(".mg_genres").select("a").mapToSet { a ->
tags = summary.selectFirst(".mg_genres")?.select("a")?.mapToSet { a ->
MangaTag(
key = a.attr("href").removeSuffix("/").substringAfterLast('/'),
title = a.text(),
source = MangaSource.MANGAREAD
)
},
}.orEmpty(),
author = summary.selectFirst(".mg_author")?.selectFirst("a")?.ownText(),
state = when (summary.selectFirst(".mg_status")?.selectFirst(".summary-content")
?.ownText()?.trim()) {
@@ -148,7 +147,7 @@ class MangareadRepository(
?: throw ParseException("Root not found")
return root.select("div.page-break").map { div ->
val img = div.selectFirst("img")
val url = img.relUrl("data-src")
val url = img.relUrl("src")
MangaPage(
id = generateUid(url),
url = url,

View File

@@ -0,0 +1,201 @@
package org.koitharu.kotatsu.core.parser.site
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.koitharu.kotatsu.base.domain.MangaLoaderContext
import org.koitharu.kotatsu.core.exceptions.ParseException
import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.utils.ext.*
import java.util.*
abstract class NineMangaRepository(
loaderContext: MangaLoaderContext,
override val source: MangaSource,
override val defaultDomain: String,
) : RemoteMangaRepository(loaderContext) {
init {
loaderContext.insertCookies(getDomain(), "ninemanga_template_desk=yes")
}
override val sortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.POPULARITY,
)
override suspend fun getList(
offset: Int,
query: String?,
sortOrder: SortOrder?,
tag: MangaTag?,
): List<Manga> {
val page = (offset / PAGE_SIZE.toFloat()).toIntUp() + 1
val url = buildString {
append("https://")
append(getDomain())
if (query.isNullOrEmpty()) {
append("/category/")
if (tag != null) {
append(tag.key)
} else {
append("index")
}
append("_")
append(page)
append(".html")
} else {
append("/search/?name_sel=&wd=")
append(query.urlEncoded())
append("&page=")
append(page)
append(".html")
}
}
val doc = loaderContext.httpGet(url, PREDEFINED_HEADERS).parseHtml()
val root = doc.body().selectFirst("ul.direlist")
?: throw ParseException("Cannot find root")
val baseHost = root.baseUri().toHttpUrl().host
return root.select("li").map { node ->
val href = node.selectFirst("a").absUrl("href")
val relUrl = href.toRelativeUrl(baseHost)
val dd = node.selectFirst("dd")
Manga(
id = generateUid(relUrl),
url = relUrl,
publicUrl = href,
title = dd.selectFirst("a.bookname").text().toCamelCase(),
altTitle = null,
coverUrl = node.selectFirst("img").absUrl("src"),
rating = Manga.NO_RATING,
author = null,
tags = emptySet(),
state = null,
source = source,
description = dd.selectFirst("p").html(),
)
}
}
override suspend fun getDetails(manga: Manga): Manga {
val doc = loaderContext.httpGet(
manga.url.withDomain() + "?waring=1",
PREDEFINED_HEADERS
).parseHtml()
val root = doc.body().selectFirst("div.manga")
?: throw ParseException("Cannot find root")
val infoRoot = root.selectFirst("div.bookintro")
?: throw ParseException("Cannot find info")
return manga.copy(
tags = infoRoot.getElementsByAttributeValue("itemprop", "genre")?.first()
?.select("a")?.mapToSet { a ->
MangaTag(
title = a.text(),
key = a.attr("href").substringBetween("/", "."),
source = source,
)
}.orEmpty(),
author = infoRoot.getElementsByAttributeValue("itemprop", "author")?.first()?.text(),
description = infoRoot.getElementsByAttributeValue("itemprop", "description")?.first()
?.html()?.substringAfter("</b>"),
chapters = root.selectFirst("div.chapterbox")?.selectFirst("ul")
?.select("li")?.asReversed()?.mapIndexed { i, li ->
val a = li.selectFirst("a")
val href = a.relUrl("href")
MangaChapter(
id = generateUid(href),
name = a.text(),
number = i + 1,
url = href,
branch = null,
source = source,
)
}
)
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val doc = loaderContext.httpGet(chapter.url.withDomain(), PREDEFINED_HEADERS).parseHtml()
return doc.body().getElementById("page")?.select("option")?.map { option ->
val url = option.attr("value")
MangaPage(
id = generateUid(url),
url = url,
referer = chapter.url.withDomain(),
preview = null,
source = source,
)
} ?: throw ParseException("Pages list not found at ${chapter.url}")
}
override suspend fun getPageUrl(page: MangaPage): String {
val doc = loaderContext.httpGet(page.url.withDomain(), PREDEFINED_HEADERS).parseHtml()
val root = doc.body()
return root.selectFirst("a.pic_download")?.absUrl("href")
?: throw ParseException("Page image not found")
}
override suspend fun getTags(): Set<MangaTag> {
val doc = loaderContext.httpGet("https://${getDomain()}/category/", PREDEFINED_HEADERS)
.parseHtml()
val root = doc.body().selectFirst("ul.genreidex")
return root.select("li").mapToSet { li ->
val a = li.selectFirst("a")
MangaTag(
title = a.text(),
key = a.attr("href").substringBetweenLast("/", "."),
source = source
)
}
}
class English(loaderContext: MangaLoaderContext) : NineMangaRepository(
loaderContext,
MangaSource.NINEMANGA_EN,
"www.ninemanga.com",
)
class Spanish(loaderContext: MangaLoaderContext) : NineMangaRepository(
loaderContext,
MangaSource.NINEMANGA_ES,
"es.ninemanga.com",
)
class Russian(loaderContext: MangaLoaderContext) : NineMangaRepository(
loaderContext,
MangaSource.NINEMANGA_RU,
"ru.ninemanga.com",
)
class Deutsch(loaderContext: MangaLoaderContext) : NineMangaRepository(
loaderContext,
MangaSource.NINEMANGA_DE,
"de.ninemanga.com",
)
class Brazil(loaderContext: MangaLoaderContext) : NineMangaRepository(
loaderContext,
MangaSource.NINEMANGA_BR,
"br.ninemanga.com",
)
class Italiano(loaderContext: MangaLoaderContext) : NineMangaRepository(
loaderContext,
MangaSource.NINEMANGA_IT,
"it.ninemanga.com",
)
class Francais(loaderContext: MangaLoaderContext) : NineMangaRepository(
loaderContext,
MangaSource.NINEMANGA_FR,
"fr.ninemanga.com",
)
private companion object {
const val PAGE_SIZE = 26
val PREDEFINED_HEADERS = Headers.Builder()
.add("Accept-Language", "en-US;q=0.7,en;q=0.3")
.build()
}
}

View File

@@ -18,10 +18,9 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
override val defaultDomain = "remanga.org"
override val sortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.UPDATED,
SortOrder.POPULARITY,
SortOrder.RATING,
SortOrder.ALPHABETICAL,
SortOrder.UPDATED,
SortOrder.NEWEST
)
@@ -162,7 +161,7 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
SortOrder.POPULARITY -> "-rating"
SortOrder.RATING -> "-votes"
SortOrder.NEWEST -> "-id"
else -> "-rating"
else -> "-chapter_date"
}
private fun parsePage(jo: JSONObject, referer: String) = MangaPage(

View File

@@ -41,6 +41,8 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
val isAmoledTheme by BoolPreferenceDelegate(KEY_THEME_AMOLED, defaultValue = false)
val isToolbarHideWhenScrolling by BoolPreferenceDelegate(KEY_HIDE_TOOLBAR, defaultValue = true)
var gridSize by IntPreferenceDelegate(KEY_GRID_SIZE, defaultValue = 100)
val readerPageSwitch by StringSetPreferenceDelegate(
@@ -99,6 +101,9 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
var hiddenSources by StringSetPreferenceDelegate(KEY_SOURCES_HIDDEN)
val isSourcesSelected: Boolean
get() = KEY_SOURCES_HIDDEN in prefs
fun getStorageDir(context: Context): File? {
val value = prefs.getString(KEY_LOCAL_STORAGE, null)?.let {
File(it)
@@ -147,6 +152,7 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
const val KEY_APP_SECTION = "app_section"
const val KEY_THEME = "theme"
const val KEY_THEME_AMOLED = "amoled_theme"
const val KEY_HIDE_TOOLBAR = "hide_toolbar"
const val KEY_SOURCES_ORDER = "sources_order"
const val KEY_SOURCES_HIDDEN = "sources_hidden"
const val KEY_TRAFFIC_WARNING = "traffic_warning"

View File

@@ -1,34 +0,0 @@
package org.koitharu.kotatsu.core.ui
import android.content.Context
import android.view.View
import androidx.annotation.DrawableRes
import com.google.android.material.chip.Chip
import org.koitharu.kotatsu.utils.ext.getThemeColor
class ChipsFactory(val context: Context) {
fun create(
convertView: Chip? = null,
text: CharSequence,
@DrawableRes iconRes: Int = 0,
tag: Any? = null,
onClickListener: View.OnClickListener? = null
): Chip {
val chip = convertView ?: Chip(context).apply {
setTextColor(context.getThemeColor(android.R.attr.textColorPrimary))
isCloseIconVisible = false
}
chip.text = text
if (iconRes == 0) {
chip.isChipIconVisible = false
} else {
chip.isCheckedIconVisible = true
chip.setChipIconResource(iconRes)
}
chip.tag = tag
chip.setEnsureMinTouchTargetSize(false)
chip.setOnClickListener(onClickListener)
return chip
}
}

View File

@@ -2,13 +2,12 @@ package org.koitharu.kotatsu.details
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.details.ui.DetailsViewModel
val detailsModule
get() = module {
viewModel { (intent: MangaIntent) ->
DetailsViewModel(intent, get(), get(), get(), get(), get(), get())
viewModel { intent ->
DetailsViewModel(intent.get(), get(), get(), get(), get(), get(), get())
}
}

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.details.ui
import android.app.ActivityOptions
import android.os.Bundle
import android.view.*
import android.widget.AdapterView
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.core.graphics.Insets
@@ -17,6 +18,7 @@ import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.databinding.FragmentChaptersBinding
import org.koitharu.kotatsu.details.ui.adapter.BranchesAdapter
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
@@ -25,7 +27,7 @@ import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState
class ChaptersFragment : BaseFragment<FragmentChaptersBinding>(),
OnListItemClickListener<MangaChapter>, ActionMode.Callback {
OnListItemClickListener<MangaChapter>, ActionMode.Callback, AdapterView.OnItemSelectedListener {
private val viewModel by sharedViewModel<DetailsViewModel>()
@@ -48,14 +50,31 @@ class ChaptersFragment : BaseFragment<FragmentChaptersBinding>(),
chaptersAdapter = ChaptersAdapter(this)
selectionDecoration = ChaptersSelectionDecoration(view.context)
with(binding.recyclerViewChapters) {
addItemDecoration(DividerItemDecoration(view.context, RecyclerView.VERTICAL))
addItemDecoration(
DividerItemDecoration(
view.context,
RecyclerView.VERTICAL
)
)
addItemDecoration(selectionDecoration!!)
setHasFixedSize(true)
adapter = chaptersAdapter
}
val branchesAdapter = BranchesAdapter()
binding.spinnerBranches.adapter = branchesAdapter
binding.spinnerBranches.onItemSelectedListener = this
viewModel.isLoading.observe(viewLifecycleOwner, this::onLoadingStateChanged)
viewModel.chapters.observe(viewLifecycleOwner, this::onChaptersChanged)
viewModel.branches.observe(viewLifecycleOwner) {
branchesAdapter.setItems(it)
binding.spinnerBranches.isVisible = it.size > 1
}
viewModel.selectedBranchIndex.observe(viewLifecycleOwner) {
if (it != -1 && it != binding.spinnerBranches.selectedItemPosition) {
binding.spinnerBranches.setSelection(it)
}
}
viewModel.isChaptersReversed.observe(viewLifecycleOwner) {
activity?.invalidateOptionsMenu()
}
@@ -64,6 +83,7 @@ class ChaptersFragment : BaseFragment<FragmentChaptersBinding>(),
override fun onDestroyView() {
chaptersAdapter = null
selectionDecoration = null
binding.spinnerBranches.adapter = null
super.onDestroyView()
}
@@ -145,6 +165,12 @@ class ChaptersFragment : BaseFragment<FragmentChaptersBinding>(),
}
}
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
viewModel.setSelectedBranch(binding.spinnerBranches.selectedItem as String?)
}
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)
@@ -174,7 +200,7 @@ class ChaptersFragment : BaseFragment<FragmentChaptersBinding>(),
binding.recyclerViewChapters.updatePadding(
left = insets.left,
right = insets.right,
bottom = insets.bottom
bottom = insets.bottom + binding.spinnerBranches.height
)
}

View File

@@ -41,7 +41,7 @@ import org.koitharu.kotatsu.utils.ext.getDisplayMessage
class DetailsActivity : BaseActivity<ActivityDetailsBinding>(),
TabLayoutMediator.TabConfigurationStrategy {
private val viewModel by viewModel<DetailsViewModel> {
private val viewModel by viewModel<DetailsViewModel>(mode = LazyThreadSafetyMode.NONE) {
parametersOf(MangaIntent.from(intent))
}
@@ -113,7 +113,7 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(),
}
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.opt_details, menu)
return super.onCreateOptionsMenu(menu)
}

View File

@@ -13,12 +13,14 @@ import androidx.core.view.updatePadding
import coil.ImageLoader
import coil.util.CoilUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.databinding.FragmentDetailsBinding
@@ -33,7 +35,8 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
View.OnLongClickListener {
private val viewModel by sharedViewModel<DetailsViewModel>()
private val coil by inject<ImageLoader>()
private val coil by inject<ImageLoader>(mode = LazyThreadSafetyMode.NONE)
private var tagsJob: Job? = null
override fun onInflateView(
inflater: LayoutInflater,
@@ -67,47 +70,11 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
ratingBar.progress = (ratingBar.max * manga.rating).roundToInt()
ratingBar.isVisible = true
}
chipsTags.removeAllViews()
manga.author?.let { a ->
chipsTags.addChips(listOf(a)) {
create(
text = it,
iconRes = R.drawable.ic_chip_user,
tag = it
)
}
}
chipsTags.addChips(manga.tags) {
create(
text = it.title,
iconRes = R.drawable.ic_chip_tag,
tag = it
)
}
manga.url.toUri().toFileOrNull()?.let { f ->
viewLifecycleScope.launch {
val size = withContext(Dispatchers.IO) {
f.length()
}
chipsTags.addChips(listOf(f)) {
create(
text = FileSizeUtils.formatBytes(context, size),
iconRes = R.drawable.ic_chip_storage,
tag = it
)
}
}
} ?: chipsTags.addChips(listOf(manga.source)) {
create(
text = it.title,
iconRes = R.drawable.ic_chip_web,
tag = it
)
}
imageViewFavourite.setOnClickListener(this@DetailsFragment)
buttonRead.setOnClickListener(this@DetailsFragment)
buttonRead.setOnLongClickListener(this@DetailsFragment)
buttonRead.isEnabled = !manga.chapters.isNullOrEmpty()
bindTags(manga)
}
}
@@ -191,4 +158,39 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
bottom = insets.bottom
)
}
private fun bindTags(manga: Manga) {
tagsJob?.cancel()
tagsJob = viewLifecycleScope.launch {
val tags = ArrayList<ChipsView.ChipModel>(manga.tags.size + 2)
if (manga.author != null) {
tags += ChipsView.ChipModel(
title = manga.author,
icon = R.drawable.ic_chip_user
)
}
for (tag in manga.tags) {
tags += ChipsView.ChipModel(
title = tag.title,
icon = R.drawable.ic_chip_tag
)
}
val file = manga.url.toUri().toFileOrNull()
if (file != null) {
val size = withContext(Dispatchers.IO) {
file.length()
}
tags += ChipsView.ChipModel(
title = FileSizeUtils.formatBytes(requireContext(), size),
icon = R.drawable.ic_chip_storage
)
} else {
tags += ChipsView.ChipModel(
title = manga.source.title,
icon = R.drawable.ic_chip_web
)
}
binding.chipsTags.setChips(tags)
}
}
}

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.details.ui
import androidx.lifecycle.asFlow
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
@@ -18,6 +19,7 @@ import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.mapToSet
import java.io.IOException
class DetailsViewModel(
@@ -31,6 +33,7 @@ class DetailsViewModel(
) : BaseViewModel() {
private val mangaData = MutableStateFlow<Manga?>(intent.manga)
private val selectedBranch = MutableStateFlow<String?>(null)
private val history = mangaData.mapNotNull { it?.id }
.distinctUntilChanged()
@@ -69,12 +72,24 @@ class DetailsViewModel(
val onMangaRemoved = SingleLiveEvent<Manga>()
val branches = mangaData.map {
it?.chapters?.mapToSet { x -> x.branch }?.sortedBy { x -> x }.orEmpty()
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
val selectedBranchIndex = combine(
branches.asFlow(),
selectedBranch
) { branches, selected ->
branches.indexOf(selected)
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
val chapters = combine(
mangaData.map { it?.chapters.orEmpty() },
history.map { it?.chapterId },
newChapters,
chaptersReversed
) { chapters, currentId, newCount, reversed ->
chaptersReversed,
selectedBranch
) { chapters, currentId, newCount, reversed, branch ->
val currentIndex = chapters.indexOfFirst { it.id == currentId }
val firstNewIndex = chapters.size - newCount
val res = chapters.mapIndexed { index, chapter ->
@@ -86,7 +101,7 @@ class DetailsViewModel(
else -> ChapterExtra.UNREAD
}
)
}
}.filter { it.chapter.branch == branch }
if (reversed) res.asReversed() else res
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
@@ -96,6 +111,15 @@ class DetailsViewModel(
?: throw MangaNotFoundException("Cannot find manga")
mangaData.value = manga
manga = manga.source.repository.getDetails(manga)
// find default branch
val hist = historyRepository.getOne(manga)
selectedBranch.value = if (hist != null) {
manga.chapters?.find { it.id == hist.chapterId }?.branch
} else {
manga.chapters
?.groupBy { it.branch }
?.maxByOrNull { it.value.size }?.key
}
mangaData.value = manga
}
}
@@ -114,4 +138,8 @@ class DetailsViewModel(
fun setChaptersReversed(newValue: Boolean) {
settings.chaptersReverse = newValue
}
fun setSelectedBranch(branch: String?) {
selectedBranch.value = branch
}
}

View File

@@ -0,0 +1,45 @@
package org.koitharu.kotatsu.details.ui.adapter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import android.widget.TextView
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.utils.ext.replaceWith
class BranchesAdapter : BaseAdapter() {
private val dataSet = ArrayList<String?>()
override fun getCount(): Int {
return dataSet.size
}
override fun getItem(position: Int): Any? {
return dataSet[position]
}
override fun getItemId(position: Int): Long {
return dataSet[position].hashCode().toLong()
}
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val view = convertView ?: LayoutInflater.from(parent.context)
.inflate(R.layout.item_branch, parent, false)
(view as TextView).text = dataSet[position]
return view
}
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
val view = convertView ?: LayoutInflater.from(parent.context)
.inflate(R.layout.item_branch_dropdown, parent, false)
(view as TextView).text = dataSet[position]
return view
}
fun setItems(items: Collection<String?>) {
dataSet.replaceWith(items)
notifyDataSetChanged()
}
}

View File

@@ -13,6 +13,7 @@ import androidx.core.graphics.drawable.toBitmap
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.utils.PendingIntentCompat
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import kotlin.math.roundToInt
@@ -63,7 +64,7 @@ class DownloadNotification(private val context: Context) {
context,
startId,
intent,
PendingIntent.FLAG_CANCEL_CURRENT
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
)
}
@@ -146,7 +147,7 @@ class DownloadNotification(private val context: Context) {
context,
manga.hashCode(),
DetailsActivity.newIntent(context, manga),
PendingIntent.FLAG_CANCEL_CURRENT
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
}
}

View File

@@ -85,7 +85,7 @@ class DownloadService : BaseService() {
wakeLock.acquire(TimeUnit.HOURS.toMillis(1))
notification.fillFrom(manga)
notification.setCancelId(startId)
withContext(Dispatchers.Main.immediate) {
withContext(Dispatchers.Main) {
startForeground(DownloadNotification.NOTIFICATION_ID, notification())
}
val destination = settings.getStorageDir(this@DownloadService)
@@ -174,8 +174,8 @@ class DownloadService : BaseService() {
withContext(NonCancellable) {
jobs.remove(startId)
output?.cleanup()
destination.sub("page.tmp").delete()
withContext(Dispatchers.Main.immediate) {
destination.sub(TEMP_PAGE_FILE).deleteAwait()
withContext(Dispatchers.Main) {
stopForeground(true)
notification.dismiss()
stopSelf(startId)
@@ -196,13 +196,25 @@ class DownloadService : BaseService() {
.cacheControl(CacheUtils.CONTROL_DISABLED)
.get()
.build()
return retryUntilSuccess(3) {
okHttp.newCall(request).await().use { response ->
val file = destination.sub("page.tmp")
file.outputStream().use { out ->
response.body!!.byteStream().copyTo(out)
val call = okHttp.newCall(request)
var attempts = MAX_DOWNLOAD_ATTEMPTS
val file = destination.sub(TEMP_PAGE_FILE)
while (true) {
try {
val response = call.clone().await()
withContext(Dispatchers.IO) {
file.outputStream().use { out ->
checkNotNull(response.body).byteStream().copyTo(out)
}
}
return file
} catch (e: IOException) {
attempts--
if (attempts <= 0) {
throw e
} else {
delay(DOWNLOAD_ERROR_DELAY)
}
file
}
}
}
@@ -218,6 +230,10 @@ class DownloadService : BaseService() {
private const val EXTRA_CHAPTERS_IDS = "chapters_ids"
private const val EXTRA_CANCEL_ID = "cancel_id"
private const val MAX_DOWNLOAD_ATTEMPTS = 3
private const val DOWNLOAD_ERROR_DELAY = 500L
private const val TEMP_PAGE_FILE = "page.tmp"
fun start(context: Context, manga: Manga, chaptersIds: Collection<Long>? = null) {
confirmDataTransfer(context) {
val intent = Intent(context, DownloadService::class.java)

View File

@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.favourites
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.categories.FavouritesCategoriesViewModel
import org.koitharu.kotatsu.favourites.ui.categories.select.MangaCategoriesViewModel
@@ -13,11 +12,11 @@ val favouritesModule
single { FavouritesRepository(get()) }
viewModel { (categoryId: Long) ->
FavouritesListViewModel(categoryId, get(), get())
viewModel { categoryId ->
FavouritesListViewModel(categoryId.get(), get(), get())
}
viewModel { FavouritesCategoriesViewModel(get()) }
viewModel { (manga: Manga) ->
MangaCategoriesViewModel(manga, get())
viewModel { manga ->
MangaCategoriesViewModel(manga.get(), get())
}
}

View File

@@ -1,12 +1,9 @@
package org.koitharu.kotatsu.favourites.domain
import androidx.collection.ArraySet
import androidx.room.withTransaction
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
@@ -83,18 +80,15 @@ class FavouritesRepository(private val db: MangaDatabase) {
categoryId = 0
)
val id = db.favouriteCategoriesDao.insert(entity)
notifyCategoriesChanged()
return entity.toFavouriteCategory(id)
}
suspend fun renameCategory(id: Long, title: String) {
db.favouriteCategoriesDao.update(id, title)
notifyCategoriesChanged()
}
suspend fun removeCategory(id: Long) {
db.favouriteCategoriesDao.delete(id)
notifyCategoriesChanged()
}
suspend fun reorderCategories(orderedIds: List<Long>) {
@@ -104,7 +98,6 @@ class FavouritesRepository(private val db: MangaDatabase) {
dao.update(id, i)
}
}
notifyCategoriesChanged()
}
suspend fun addToCategory(manga: Manga, categoryId: Long) {
@@ -115,41 +108,13 @@ class FavouritesRepository(private val db: MangaDatabase) {
val entity = FavouriteEntity(manga.id, categoryId, System.currentTimeMillis())
db.favouritesDao.insert(entity)
}
notifyFavouritesChanged(manga.id)
}
suspend fun removeFromCategory(manga: Manga, categoryId: Long) {
db.favouritesDao.delete(categoryId, manga.id)
notifyFavouritesChanged(manga.id)
}
suspend fun removeFromFavourites(manga: Manga) {
db.favouritesDao.delete(manga.id)
notifyFavouritesChanged(manga.id)
}
companion object {
private val listeners = ArraySet<OnFavouritesChangeListener>()
fun subscribe(listener: OnFavouritesChangeListener) {
listeners += listener
}
fun unsubscribe(listener: OnFavouritesChangeListener) {
listeners -= listener
}
private suspend fun notifyFavouritesChanged(mangaId: Long) {
withContext(Dispatchers.Main) {
listeners.forEach { x -> x.onFavouritesChanged(mangaId) }
}
}
private suspend fun notifyCategoriesChanged() {
withContext(Dispatchers.Main) {
listeners.forEach { x -> x.onCategoriesChanged() }
}
}
}
}

View File

@@ -1,9 +0,0 @@
package org.koitharu.kotatsu.favourites.domain
@Deprecated("Use flow")
fun interface OnFavouritesChangeListener {
fun onFavouritesChanged(mangaId: Long)
fun onCategoriesChanged() = Unit
}

View File

@@ -27,7 +27,9 @@ class FavouritesContainerFragment : BaseFragment<FragmentFavouritesBinding>(),
override val recycledViewPool = RecyclerView.RecycledViewPool()
private val viewModel by viewModel<FavouritesCategoriesViewModel>()
private val viewModel by viewModel<FavouritesCategoriesViewModel>(
mode = LazyThreadSafetyMode.NONE
)
private val editDelegate by lazy(LazyThreadSafetyMode.NONE) {
CategoriesEditDelegate(requireContext(), this)
}

View File

@@ -25,6 +25,14 @@ class FavouritesPagerAdapter(
return FavouritesListFragment.newInstance(item.id)
}
override fun getItemId(position: Int): Long {
return differ.currentList[position].id
}
override fun containsItem(itemId: Long): Boolean {
return differ.currentList.any { it.id == itemId }
}
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
val item = differ.currentList[position]
tab.text = item.title

View File

@@ -28,7 +28,9 @@ class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
OnListItemClickListener<FavouriteCategory>,
View.OnClickListener, CategoriesEditDelegate.CategoriesEditCallback {
private val viewModel by viewModel<FavouritesCategoriesViewModel>()
private val viewModel by viewModel<FavouritesCategoriesViewModel>(
mode = LazyThreadSafetyMode.NONE
)
private lateinit var adapter: CategoriesAdapter
private lateinit var reorderHelper: ItemTouchHelper

View File

@@ -25,7 +25,7 @@ class FavouriteCategoriesDialog : BaseBottomSheet<DialogFavoriteCategoriesBindin
OnListItemClickListener<MangaCategoryItem>, CategoriesEditDelegate.CategoriesEditCallback,
View.OnClickListener {
private val viewModel by viewModel<MangaCategoriesViewModel> {
private val viewModel by viewModel<MangaCategoriesViewModel>(mode = LazyThreadSafetyMode.NONE) {
parametersOf(requireNotNull(arguments?.getParcelable<Manga>(MangaIntent.KEY_MANGA)))
}

View File

@@ -12,7 +12,7 @@ import org.koitharu.kotatsu.utils.ext.withArgs
class FavouritesListFragment : MangaListFragment() {
override val viewModel by viewModel<FavouritesListViewModel> {
override val viewModel by viewModel<FavouritesListViewModel>(mode = LazyThreadSafetyMode.NONE) {
parametersOf(categoryId)
}

View File

@@ -8,6 +8,6 @@ import org.koitharu.kotatsu.history.ui.HistoryListViewModel
val historyModule
get() = module {
single { HistoryRepository(get()) }
single { HistoryRepository(get(), get()) }
viewModel { HistoryListViewModel(get(), get(), get()) }
}

View File

@@ -1,13 +1,8 @@
package org.koitharu.kotatsu.history.domain
import androidx.collection.ArraySet
import androidx.room.withTransaction
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
@@ -18,9 +13,10 @@ 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) : KoinComponent {
private val trackingRepository by inject<TrackingRepository>()
class HistoryRepository(
private val db: MangaDatabase,
private val trackingRepository: TrackingRepository,
) {
suspend fun getList(offset: Int, limit: Int = 20): List<Manga> {
val entities = db.historyDao.findAll(offset, limit)
@@ -65,7 +61,6 @@ class HistoryRepository(private val db: MangaDatabase) : KoinComponent {
)
trackingRepository.upsert(manga)
}
notifyHistoryChanged()
}
suspend fun getOne(manga: Manga): MangaHistory? {
@@ -74,12 +69,10 @@ class HistoryRepository(private val db: MangaDatabase) : KoinComponent {
suspend fun clear() {
db.historyDao.clear()
notifyHistoryChanged()
}
suspend fun delete(manga: Manga) {
db.historyDao.delete(manga.id)
notifyHistoryChanged()
}
/**
@@ -89,26 +82,6 @@ class HistoryRepository(private val db: MangaDatabase) : KoinComponent {
suspend fun deleteOrSwap(manga: Manga, alternative: Manga?) {
if (alternative == null || db.mangaDao.update(MangaEntity.from(alternative)) <= 0) {
db.historyDao.delete(manga.id)
notifyHistoryChanged()
}
}
companion object {
private val listeners = ArraySet<OnHistoryChangeListener>()
fun subscribe(listener: OnHistoryChangeListener) {
listeners += listener
}
fun unsubscribe(listener: OnHistoryChangeListener) {
listeners -= listener
}
private suspend fun notifyHistoryChanged() {
withContext(Dispatchers.Main) {
listeners.forEach { x -> x.onHistoryChanged() }
}
}
}
}

View File

@@ -1,6 +0,0 @@
package org.koitharu.kotatsu.history.domain
fun interface OnHistoryChangeListener {
fun onHistoryChanged()
}

View File

@@ -15,7 +15,7 @@ import org.koitharu.kotatsu.utils.ext.ellipsize
class HistoryListFragment : MangaListFragment() {
override val viewModel by viewModel<HistoryListViewModel>()
override val viewModel by viewModel<HistoryListViewModel>(mode = LazyThreadSafetyMode.NONE)
override val isSwipeRefreshEnabled = false
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

View File

@@ -18,7 +18,7 @@ import org.koitharu.kotatsu.databinding.DialogListModeBinding
class ListModeSelectDialog : AlertDialogFragment<DialogListModeBinding>(), View.OnClickListener,
SeekBar.OnSeekBarChangeListener {
private val settings by inject<AppSettings>()
private val settings by inject<AppSettings>(mode = LazyThreadSafetyMode.NONE)
private var mode: ListMode = ListMode.GRID
private var pendingGridSize: Int = 100

View File

@@ -55,6 +55,7 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
spanSizeLookup.invalidateCache()
}
open val isSwipeRefreshEnabled = true
private var drawer: DrawerLayout? = null
protected abstract val viewModel: MangaListViewModel
@@ -70,7 +71,8 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.drawer?.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
drawer = binding.root as? DrawerLayout
drawer?.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
listAdapter = MangaListAdapter(get(), viewLifecycleOwner, this, ::resolveException)
paginationListener = PaginationScrollListener(4, this)
with(binding.recyclerView) {
@@ -101,6 +103,7 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
}
override fun onDestroyView() {
drawer = null
listAdapter = null
paginationListener = null
spanSizeLookup.invalidateCache()
@@ -118,15 +121,15 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
true
}
R.id.action_filter -> {
binding.drawer?.toggleDrawer(GravityCompat.END)
drawer?.toggleDrawer(GravityCompat.END)
true
}
else -> super.onOptionsItemSelected(item)
}
override fun onPrepareOptionsMenu(menu: Menu) {
menu.findItem(R.id.action_filter).isVisible = binding.drawer != null &&
binding.drawer?.getDrawerLockMode(GravityCompat.END) != DrawerLayout.LOCK_MODE_LOCKED_CLOSED
menu.findItem(R.id.action_filter).isVisible = drawer != null &&
drawer?.getDrawerLockMode(GravityCompat.END) != DrawerLayout.LOCK_MODE_LOCKED_CLOSED
super.onPrepareOptionsMenu(menu)
}
@@ -199,7 +202,7 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
state = config.currentFilter,
listener = this
)
binding.drawer?.setDrawerLockMode(
drawer?.setDrawerLockMode(
if (config.sortOrders.isEmpty() && config.tags.isEmpty()) {
DrawerLayout.LOCK_MODE_LOCKED_CLOSED
} else {
@@ -214,7 +217,7 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
@CallSuper
override fun onFilterChanged(filter: MangaFilter) {
binding.drawer?.closeDrawers()
drawer?.closeDrawers()
}
override fun onWindowInsetsChanged(insets: Insets) {

View File

@@ -72,6 +72,7 @@ class MangaIndex(source: String?) {
jo.put("number", chapter.number)
jo.put("url", chapter.url)
jo.put("name", chapter.name)
jo.put("branch", chapter.branch)
jo.put("entries", "%03d\\d{3}".format(chapter.number))
chapters.put(chapter.id.toString(), jo)
}
@@ -97,6 +98,7 @@ class MangaIndex(source: String?) {
name = v.getString("name"),
url = v.getString("url"),
number = v.getInt("number"),
branch = v.getStringOrNull("branch"),
source = source
)
)

View File

@@ -4,7 +4,7 @@ import android.content.Context
import com.tomclaw.cache.DiskLruCache
import org.koitharu.kotatsu.utils.FileSizeUtils
import org.koitharu.kotatsu.utils.ext.longHashCode
import org.koitharu.kotatsu.utils.ext.sub
import org.koitharu.kotatsu.utils.ext.subdir
import org.koitharu.kotatsu.utils.ext.takeIfReadable
import java.io.File
import java.io.InputStream
@@ -13,8 +13,10 @@ import java.io.OutputStream
class PagesCache(context: Context) {
private val cacheDir = context.externalCacheDir ?: context.cacheDir
private val lruCache =
DiskLruCache.create(cacheDir.sub(Cache.PAGES.dir), FileSizeUtils.mbToBytes(200))
private val lruCache = DiskLruCache.create(
cacheDir.subdir(Cache.PAGES.dir),
FileSizeUtils.mbToBytes(200)
)
operator fun get(url: String): File? {
return lruCache.get(url)?.takeIfReadable()
@@ -22,7 +24,7 @@ class PagesCache(context: Context) {
@Deprecated("Useless lambda")
fun put(url: String, writer: (OutputStream) -> Unit): File {
val file = cacheDir.sub(url.longHashCode().toString())
val file = File(cacheDir, url.longHashCode().toString())
file.outputStream().use(writer)
val res = lruCache.put(url, file)
file.delete()
@@ -30,7 +32,7 @@ class PagesCache(context: Context) {
}
fun put(url: String, inputStream: InputStream): File {
val file = cacheDir.sub(url.longHashCode().toString())
val file = File(cacheDir, url.longHashCode().toString())
file.outputStream().use { out ->
inputStream.copyTo(out)
}

View File

@@ -7,6 +7,8 @@ import android.webkit.MimeTypeMap
import androidx.collection.ArraySet
import androidx.core.net.toFile
import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.local.data.CbzFilter
@@ -127,14 +129,16 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
)
}
fun getRemoteManga(localManga: Manga): Manga? {
suspend fun getRemoteManga(localManga: Manga): Manga? {
val file = runCatching {
Uri.parse(localManga.url).toFile()
}.getOrNull() ?: return null
val zip = ZipFile(file)
val entry = zip.getEntry(MangaZip.INDEX_ENTRY)
val index = entry?.let(zip::readText)?.let(::MangaIndex) ?: return null
return index.getMangaInfo()
return withContext(Dispatchers.IO) {
val zip = ZipFile(file)
val entry = zip.getEntry(MangaZip.INDEX_ENTRY)
val index = entry?.let(zip::readText)?.let(::MangaIndex) ?: return@withContext null
index.getMangaInfo()
}
}
private fun zipUri(file: File, entryName: String) =
@@ -171,10 +175,12 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
}
fun getAvailableStorageDirs(context: Context): List<File> {
val result = ArrayList<File>(5)
result += context.filesDir.sub(DIR_NAME)
val result = ArrayList<File?>(5)
result += File(context.filesDir, DIR_NAME)
result += context.getExternalFilesDirs(DIR_NAME)
return result.distinctBy { it.canonicalPath }.filter { it.exists() || it.mkdir() }
return result.filterNotNull()
.distinctBy { it.canonicalPath }
.filter { it.exists() || it.mkdir() }
}
fun getFallbackStorageDir(context: Context): File? {

View File

@@ -20,7 +20,7 @@ import org.koitharu.kotatsu.utils.ext.ellipsize
class LocalListFragment : MangaListFragment(), ActivityResultCallback<Uri> {
override val viewModel by viewModel<LocalListViewModel>()
override val viewModel by viewModel<LocalListViewModel>(mode = LazyThreadSafetyMode.NONE)
private val importCall = registerForActivityResult(
ActivityResultContracts.OpenDocument(),
this

View File

@@ -99,6 +99,7 @@ class LocalListViewModel(
runCatching {
historyRepository.deleteOrSwap(manga, original)
}
mangaList.value = mangaList.value?.filterNot { it.id == manga.id }
}
shortcutsRepository.updateShortcuts()
onMangaRemoved.call(manga)

View File

@@ -13,5 +13,5 @@ val mainModule
single { AppProtectHelper(get()) }
single { ShortcutsRepository(androidContext(), get(), get(), get()) }
viewModel { MainViewModel(get(), get()) }
viewModel { ProtectViewModel(get()) }
viewModel { ProtectViewModel(get(), get()) }
}

View File

@@ -11,13 +11,16 @@ import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.app.AlertDialog
import androidx.core.graphics.Insets
import androidx.core.view.*
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentTransaction
import androidx.fragment.app.commit
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import com.google.android.material.navigation.NavigationView
import com.google.android.material.snackbar.Snackbar
import org.koin.android.ext.android.inject
import org.koin.android.ext.android.get
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
@@ -25,40 +28,42 @@ import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.prefs.AppSection
import org.koitharu.kotatsu.databinding.ActivityMainBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.favourites.ui.FavouritesContainerFragment
import org.koitharu.kotatsu.history.ui.HistoryListFragment
import org.koitharu.kotatsu.local.ui.LocalListFragment
import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
import org.koitharu.kotatsu.search.ui.SearchHelper
import org.koitharu.kotatsu.search.ui.SearchActivity
import org.koitharu.kotatsu.search.ui.global.GlobalSearchActivity
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionFragment
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
import org.koitharu.kotatsu.search.ui.suggestion.SearchUI
import org.koitharu.kotatsu.settings.AppUpdateChecker
import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment
import org.koitharu.kotatsu.tracker.ui.FeedFragment
import org.koitharu.kotatsu.tracker.work.TrackWorker
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.resolveDp
import java.io.Closeable
class MainActivity : BaseActivity<ActivityMainBinding>(),
NavigationView.OnNavigationItemSelectedListener,
View.OnClickListener {
View.OnClickListener, SearchSuggestionListener, MenuItem.OnActionExpandListener {
private val viewModel by viewModel<MainViewModel>()
private val protectHelper by inject<AppProtectHelper>()
private val viewModel by viewModel<MainViewModel>(mode = LazyThreadSafetyMode.NONE)
private val searchSuggestionViewModel by viewModel<SearchSuggestionViewModel>(
mode = LazyThreadSafetyMode.NONE
)
private lateinit var drawerToggle: ActionBarDrawerToggle
private var closeable: Closeable? = null
private var searchUi: SearchUI? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (protectHelper.check(this)) {
finish()
return
}
setContentView(ActivityMainBinding.inflate(layoutInflater))
drawerToggle =
ActionBarDrawerToggle(
drawerToggle = ActionBarDrawerToggle(
this,
binding.drawer,
binding.toolbar,
@@ -75,7 +80,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
setOnClickListener(this@MainActivity)
}
supportFragmentManager.findFragmentById(R.id.container)?.let {
supportFragmentManager.findFragmentByTag(TAG_PRIMARY)?.let {
binding.fab.isVisible = it is HistoryListFragment
} ?: run {
openDefaultSection()
@@ -83,6 +88,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
if (savedInstanceState == null) {
TrackWorker.setup(applicationContext)
AppUpdateChecker(this).launchIfNeeded()
OnboardDialogFragment.showWelcome(get(), supportFragmentManager)
}
viewModel.onOpenReader.observe(this, this::onOpenReader)
@@ -91,12 +97,6 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
viewModel.remoteSources.observe(this, this::updateSideMenu)
}
override fun onDestroy() {
closeable?.close()
protectHelper.lock()
super.onDestroy()
}
override fun onPostCreate(savedInstanceState: Bundle?) {
super.onPostCreate(savedInstanceState)
drawerToggle.syncState()
@@ -117,8 +117,10 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.opt_main, menu)
menu.findItem(R.id.action_search)?.let { menuItem ->
closeable = SearchHelper.setupSearchView(menuItem)
searchUi = menu.findItem(R.id.action_search)?.let { menuItem ->
onMenuItemActionCollapse(menuItem)
menuItem.setOnActionExpandListener(this)
SearchUI.from(menuItem, this)
}
return super.onCreateOptionsMenu(menu)
}
@@ -139,28 +141,32 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
if (item.groupId == R.id.group_remote_sources) {
val source = MangaSource.values().getOrNull(item.itemId) ?: return false
setPrimaryFragment(RemoteListFragment.newInstance(source))
} else when (item.itemId) {
R.id.nav_history -> {
viewModel.defaultSection = AppSection.HISTORY
setPrimaryFragment(HistoryListFragment.newInstance())
searchSuggestionViewModel.onSourceChanged(source)
} else {
searchSuggestionViewModel.onSourceChanged(null)
when (item.itemId) {
R.id.nav_history -> {
viewModel.defaultSection = AppSection.HISTORY
setPrimaryFragment(HistoryListFragment.newInstance())
}
R.id.nav_favourites -> {
viewModel.defaultSection = AppSection.FAVOURITES
setPrimaryFragment(FavouritesContainerFragment.newInstance())
}
R.id.nav_local_storage -> {
viewModel.defaultSection = AppSection.LOCAL
setPrimaryFragment(LocalListFragment.newInstance())
}
R.id.nav_feed -> {
viewModel.defaultSection = AppSection.FEED
setPrimaryFragment(FeedFragment.newInstance())
}
R.id.nav_action_settings -> {
startActivity(SettingsActivity.newIntent(this))
return true
}
else -> return false
}
R.id.nav_favourites -> {
viewModel.defaultSection = AppSection.FAVOURITES
setPrimaryFragment(FavouritesContainerFragment.newInstance())
}
R.id.nav_local_storage -> {
viewModel.defaultSection = AppSection.LOCAL
setPrimaryFragment(LocalListFragment.newInstance())
}
R.id.nav_feed -> {
viewModel.defaultSection = AppSection.FEED
setPrimaryFragment(FeedFragment.newInstance())
}
R.id.nav_action_settings -> {
startActivity(SettingsActivity.newIntent(this))
return true
}
else -> return false
}
binding.drawer.closeDrawers()
return true
@@ -179,6 +185,62 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
}
}
override fun onMangaClick(manga: Manga) {
startActivity(DetailsActivity.newIntent(this, manga))
}
override fun onQueryClick(query: String, submit: Boolean) {
if (submit) {
if (query.isNotEmpty()) {
val source = searchSuggestionViewModel.getLocalSearchSource()
if (source != null) {
startActivity(SearchActivity.newIntent(this, source, query))
} else {
startActivity(GlobalSearchActivity.newIntent(this, query))
}
searchSuggestionViewModel.saveQuery(query)
}
} else {
searchUi?.query = query
}
}
override fun onQueryChanged(query: String) {
searchSuggestionViewModel.onQueryChanged(query)
}
override fun onClearSearchHistory() {
AlertDialog.Builder(this)
.setTitle(R.string.clear_search_history)
.setMessage(R.string.text_clear_search_history_prompt)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.clear) { _, _ ->
searchSuggestionViewModel.clearSearchHistory()
}.show()
}
override fun onMenuItemActionExpand(item: MenuItem?): Boolean {
val fragment = supportFragmentManager.findFragmentByTag(TAG_SEARCH)
if (fragment == null) {
supportFragmentManager.commit {
add(R.id.container, SearchSuggestionFragment.newInstance(), TAG_SEARCH)
setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
}
}
return true
}
override fun onMenuItemActionCollapse(item: MenuItem?): Boolean {
val fragment = supportFragmentManager.findFragmentByTag(TAG_SEARCH)
if (fragment != null) {
supportFragmentManager.commit {
remove(fragment)
setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
}
}
return true
}
private fun onOpenReader(manga: Manga) {
val options = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
ActivityOptions.makeClipRevealAnimation(
@@ -242,8 +304,14 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
private fun setPrimaryFragment(fragment: Fragment) {
supportFragmentManager.beginTransaction()
.replace(R.id.container, fragment)
.replace(R.id.container, fragment, TAG_PRIMARY)
.commit()
binding.fab.isVisible = fragment is HistoryListFragment
}
private companion object {
const val TAG_PRIMARY = "primary"
const val TAG_SEARCH = "search"
}
}

View File

@@ -1,33 +1,49 @@
package org.koitharu.kotatsu.main.ui.protect
import android.app.Activity
import android.app.Application
import android.content.Intent
import android.os.Bundle
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.main.ui.MainActivity
class AppProtectHelper(private val settings: AppSettings) {
class AppProtectHelper(private val settings: AppSettings) : Application.ActivityLifecycleCallbacks {
private var isUnlocked = settings.appPassword.isNullOrEmpty()
fun unlock(activity: Activity) {
isUnlocked = true
with(activity) {
startActivity(Intent(this, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP))
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
if (activity !is ProtectActivity && !isUnlocked) {
val sourceIntent = Intent(activity, activity.javaClass)
activity.intent?.let {
sourceIntent.putExtras(it)
sourceIntent.action = it.action
sourceIntent.setDataAndType(it.data, it.type)
}
activity.startActivity(ProtectActivity.newIntent(activity, sourceIntent))
activity.finishAfterTransition()
}
}
fun lock() {
override fun onActivityStarted(activity: Activity) = Unit
override fun onActivityResumed(activity: Activity) = Unit
override fun onActivityPaused(activity: Activity) = Unit
override fun onActivityStopped(activity: Activity) = Unit
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
override fun onActivityDestroyed(activity: Activity) {
if (activity !is ProtectActivity && activity.isTaskRoot) {
restoreLock()
}
}
fun unlock() {
isUnlocked = true
}
private fun restoreLock() {
isUnlocked = settings.appPassword.isNullOrEmpty()
}
fun check(activity: Activity): Boolean {
return if (!isUnlocked) {
activity.startActivity(ProtectActivity.newIntent(activity)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP))
true
} else {
false
}
}
}

View File

@@ -6,13 +6,10 @@ import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.view.KeyEvent
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.inputmethod.EditorInfo
import android.widget.TextView
import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
@@ -20,50 +17,49 @@ import org.koitharu.kotatsu.databinding.ActivityProtectBinding
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
class ProtectActivity : BaseActivity<ActivityProtectBinding>(), TextView.OnEditorActionListener,
TextWatcher {
TextWatcher, View.OnClickListener {
private val viewModel by viewModel<ProtectViewModel>()
private val appProtectHelper by inject<AppProtectHelper>()
private val viewModel by viewModel<ProtectViewModel>(mode = LazyThreadSafetyMode.NONE)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityProtectBinding.inflate(layoutInflater))
binding.editPassword.setOnEditorActionListener(this)
binding.editPassword.addTextChangedListener(this)
supportActionBar?.run {
setDisplayHomeAsUpEnabled(true)
setHomeAsUpIndicator(R.drawable.ic_cross)
}
binding.buttonNext.setOnClickListener(this)
binding.buttonCancel.setOnClickListener(this)
viewModel.onError.observe(this, this::onError)
viewModel.isLoading.observe(this, this::onLoadingStateChanged)
viewModel.onUnlockSuccess.observe(this, this::onUnlockSuccess)
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.opt_protect, menu)
return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.action_done -> {
viewModel.tryUnlock(binding.editPassword.text.toString().orEmpty())
true
viewModel.onUnlockSuccess.observe(this) {
val intent = intent.getParcelableExtra<Intent>(EXTRA_INTENT)
startActivity(intent)
finishAfterTransition()
}
else -> super.onOptionsItemSelected(item)
binding.editPassword.requestFocus()
}
override fun onWindowInsetsChanged(insets: Insets) {
binding.toolbar.updatePadding(
left = insets.left,
right = insets.right,
top = insets.top
val basePadding = resources.getDimensionPixelOffset(R.dimen.screen_padding)
binding.root.setPadding(
basePadding + insets.left,
basePadding + insets.top,
basePadding + insets.right,
basePadding + insets.bottom
)
}
override fun onClick(v: View) {
when (v.id) {
R.id.button_next -> viewModel.tryUnlock(binding.editPassword.text?.toString().orEmpty())
R.id.button_cancel -> finish()
}
}
override fun onEditorAction(v: TextView?, actionId: Int, event: KeyEvent?): Boolean {
return if (actionId == EditorInfo.IME_ACTION_DONE) {
viewModel.tryUnlock(binding.editPassword.text.toString().orEmpty())
return if (actionId == EditorInfo.IME_ACTION_DONE && binding.buttonNext.isEnabled) {
binding.buttonNext.performClick()
true
} else {
false
@@ -76,10 +72,7 @@ class ProtectActivity : BaseActivity<ActivityProtectBinding>(), TextView.OnEdito
override fun afterTextChanged(s: Editable?) {
binding.layoutPassword.error = null
}
private fun onUnlockSuccess(unit: Unit) {
appProtectHelper.unlock(this)
binding.buttonNext.isEnabled = !s.isNullOrEmpty()
}
private fun onError(e: Throwable) {
@@ -92,6 +85,11 @@ class ProtectActivity : BaseActivity<ActivityProtectBinding>(), TextView.OnEdito
companion object {
fun newIntent(context: Context) = Intent(context, ProtectActivity::class.java)
private const val EXTRA_INTENT = "src_intent"
fun newIntent(context: Context, sourceIntent: Intent): Intent {
return Intent(context, ProtectActivity::class.java)
.putExtra(EXTRA_INTENT, sourceIntent)
}
}
}

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.main.ui.protect
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.exceptions.WrongPasswordException
@@ -8,16 +9,23 @@ import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.md5
class ProtectViewModel(
private val settings: AppSettings
private val settings: AppSettings,
private val protectHelper: AppProtectHelper,
) : BaseViewModel() {
private var job: Job? = null
val onUnlockSuccess = SingleLiveEvent<Unit>()
fun tryUnlock(password: String) {
launchLoadingJob {
if (job?.isActive == true) {
return
}
job = launchLoadingJob {
val passwordHash = password.md5()
val appPasswordHash = settings.appPassword
if (passwordHash == appPasswordHash) {
protectHelper.unlock()
onUnlockSuccess.call(Unit)
} else {
delay(PASSWORD_COMPARE_DELAY)

View File

@@ -3,9 +3,7 @@ package org.koitharu.kotatsu.reader
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.ReaderViewModel
val readerModule
@@ -14,7 +12,7 @@ val readerModule
single { MangaDataRepository(get()) }
single { PagesCache(get()) }
viewModel { (intent: MangaIntent, state: ReaderState?) ->
ReaderViewModel(intent, state, get(), get(), get(), get())
viewModel { params ->
ReaderViewModel(params[0], params[1], get(), get(), get(), get())
}
}

View File

@@ -88,12 +88,12 @@ class PageLoader(
}
suspend fun convertInPlace(file: File) {
convertLock.withLock(file) {
convertLock.withLock(Lock) {
withContext(Dispatchers.Default) {
val image = BitmapFactory.decodeFile(file.absolutePath)
try {
file.outputStream().use { out ->
image.compress(Bitmap.CompressFormat.WEBP, 100, out)
image.compress(Bitmap.CompressFormat.PNG, 100, out)
}
} finally {
image.recycle()
@@ -101,4 +101,6 @@ class PageLoader(
}
}
}
private companion object Lock
}

View File

@@ -52,7 +52,7 @@ class ReaderActivity : BaseFullscreenActivity<ActivityReaderBinding>(),
GridTouchHelper.OnGridTouchListener, OnPageSelectListener, ReaderConfigDialog.Callback,
ActivityResultCallback<Boolean>, ReaderControlDelegate.OnInteractionListener {
private val viewModel by viewModel<ReaderViewModel> {
private val viewModel by viewModel<ReaderViewModel>(mode = LazyThreadSafetyMode.NONE) {
parametersOf(MangaIntent.from(intent), intent?.getParcelableExtra<ReaderState>(EXTRA_STATE))
}
@@ -86,7 +86,7 @@ class ReaderActivity : BaseFullscreenActivity<ActivityReaderBinding>(),
viewModel.onError.observe(this, this::onError)
viewModel.readerMode.observe(this, this::onInitReader)
viewModel.onPageSaved.observe(this, this::onPageSaved)
viewModel.uiState.observe(this, this::onUiStateChanged)
viewModel.uiState.observeWithPrevious(this, this::onUiStateChanged)
viewModel.isLoading.observe(this, this::onLoadingStateChanged)
viewModel.content.observe(this) {
onLoadingStateChanged(viewModel.isLoading.value == true)
@@ -124,12 +124,7 @@ class ReaderActivity : BaseFullscreenActivity<ActivityReaderBinding>(),
}
}
override fun onPause() {
viewModel.saveCurrentState(reader?.getCurrentState())
super.onPause()
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.opt_reader_top, menu)
return super.onCreateOptionsMenu(menu)
}
@@ -204,7 +199,11 @@ class ReaderActivity : BaseFullscreenActivity<ActivityReaderBinding>(),
private fun onLoadingStateChanged(isLoading: Boolean) {
val hasPages = !viewModel.content.value?.pages.isNullOrEmpty()
binding.layoutLoading.isVisible = isLoading && !hasPages
binding.progressBarBottom.isVisible = isLoading && hasPages
if (isLoading && hasPages) {
binding.toastView.show(R.string.loading_, true)
} else {
binding.toastView.hide()
}
}
private fun onError(e: Throwable) {
@@ -336,19 +335,25 @@ class ReaderActivity : BaseFullscreenActivity<ActivityReaderBinding>(),
setUiIsVisible(!binding.appbarTop.isVisible)
}
private fun onUiStateChanged(uiState: ReaderUiState) {
private fun onUiStateChanged(uiState: ReaderUiState, previous: ReaderUiState?) {
title = uiState.chapterName ?: uiState.mangaName ?: getString(R.string.loading_)
supportActionBar?.subtitle = if (uiState.chapterNumber in 1..uiState.chaptersTotal) {
getString(R.string.chapter_d_of_d, uiState.chapterNumber, uiState.chaptersTotal)
} else {
null
}
if (previous?.chapterName != null && uiState.chapterName != previous.chapterName) {
if (!uiState.chapterName.isNullOrEmpty()) {
binding.toastView.showTemporary(uiState.chapterName, TOAST_DURATION)
}
}
}
companion object {
const val ACTION_MANGA_READ = "${BuildConfig.APPLICATION_ID}.action.READ_MANGA"
private const val EXTRA_STATE = "state"
private const val TOAST_DURATION = 1500L
fun newIntent(context: Context, manga: Manga, state: ReaderState?): Intent {
return Intent(context, ReaderActivity::class.java)

View File

@@ -0,0 +1,56 @@
package org.koitharu.kotatsu.reader.ui
import android.content.Context
import android.graphics.Color
import android.util.AttributeSet
import androidx.annotation.StringRes
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import com.google.android.material.textview.MaterialTextView
import org.koitharu.kotatsu.utils.anim.Duration
import org.koitharu.kotatsu.utils.anim.Motion
import org.koitharu.kotatsu.utils.ext.hideAnimated
import org.koitharu.kotatsu.utils.ext.showAnimated
class ReaderToastView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : MaterialTextView(context, attrs, defStyleAttr) {
private var hideRunnable = Runnable {
hide()
}
fun show(message: CharSequence, isLoading: Boolean) {
removeCallbacks(hideRunnable)
text = message
this.showAnimated(Motion.Toast, Duration.SHORT)
}
fun show(@StringRes messageId: Int, isLoading: Boolean) {
show(context.getString(messageId), isLoading)
}
fun showTemporary(message: CharSequence, duration: Long) {
show(message, false)
postDelayed(hideRunnable, duration)
}
fun hide() {
removeCallbacks(hideRunnable)
this.hideAnimated(Motion.Toast, Duration.SHORT)
}
override fun onDetachedFromWindow() {
removeCallbacks(hideRunnable)
super.onDetachedFromWindow()
}
// FIXME use it as compound drawable
private fun createProgressDrawable(): CircularProgressDrawable {
val drawable = CircularProgressDrawable(context)
drawable.setStyle(CircularProgressDrawable.DEFAULT)
drawable.arrowEnabled = false
drawable.setColorSchemeColors(Color.WHITE)
drawable.centerRadius = lineHeight / 3f
return drawable
}
}

View File

@@ -8,8 +8,6 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import okhttp3.OkHttpClient
import okhttp3.Request
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koitharu.kotatsu.base.domain.MangaDataRepository
@@ -20,11 +18,11 @@ import org.koitharu.kotatsu.core.exceptions.MangaNotFoundException
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.core.model.MangaPage
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.os.ShortcutsRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
import org.koitharu.kotatsu.utils.MediaStoreCompat
@@ -42,7 +40,7 @@ class ReaderViewModel(
private var loadingJob: Job? = null
private val currentState = MutableStateFlow<ReaderState?>(null)
private val mangaData = MutableStateFlow<Manga?>(intent.manga)
private val mangaData = MutableStateFlow(intent.manga)
private val chapters = LongSparseArray<MangaChapter>()
val readerMode = MutableLiveData<ReaderMode>()
@@ -60,7 +58,7 @@ class ReaderViewModel(
)
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
val content = MutableLiveData<ReaderContent>(ReaderContent(emptyList(), null))
val content = MutableLiveData(ReaderContent(emptyList(), null))
val manga: Manga?
get() = mangaData.value
@@ -82,7 +80,6 @@ class ReaderViewModel(
manga.chapters?.forEach {
chapters.put(it.id, it)
}
mangaData.value = manga
// determine mode
val mode =
dataRepository.getReaderMode(manga.id) ?: manga.chapters?.randomOrNull()?.let {
@@ -98,6 +95,9 @@ class ReaderViewModel(
currentState.value = state ?: historyRepository.getOne(manga)?.let {
ReaderState.from(it)
} ?: ReaderState.initial(manga)
val branch = chapters[currentState.value?.chapterId ?: 0L].branch
mangaData.value = manga.copy(chapters = manga.chapters?.filter { it.branch == branch })
readerMode.postValue(mode)
val pages = loadChapter(requireNotNull(currentState.value).chapterId)
@@ -156,20 +156,11 @@ class ReaderViewModel(
}?.toMangaPage() ?: error("Page not found")
val repo = page.source.repository
val pageUrl = repo.getPageUrl(page)
val request = Request.Builder()
.url(pageUrl)
.header(CommonHeaders.REFERER, page.referer)
.get()
.build()
val uri = get<OkHttpClient>().newCall(request).await().use { response ->
val fileName =
URLUtil.guessFileName(
pageUrl,
response.contentDisposition,
response.mimeType
)
val file = get<PagesCache>()[pageUrl] ?: error("Page not found in cache")
val uri = file.inputStream().use { input ->
val fileName = URLUtil.guessFileName(pageUrl, null, null)
MediaStoreCompat(resolver).insertImage(fileName) {
checkNotNull(response.body).byteStream().copyTo(it)
input.copyTo(it)
}
}
onPageSaved.postCall(uri)

View File

@@ -18,29 +18,34 @@ abstract class BaseReader<B : ViewBinding> : BaseFragment<B>() {
protected val loader by lazy(LazyThreadSafetyMode.NONE) {
PageLoader(lifecycleScope, get(), get())
}
private var lastReaderState: ReaderState? = null
private var stateToSave: ReaderState? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
lastReaderState = savedInstanceState?.getParcelable(KEY_STATE) ?: lastReaderState
var restoredState = savedInstanceState?.getParcelable<ReaderState?>(KEY_STATE)
viewModel.content.observe(viewLifecycleOwner) {
onPagesChanged(it.pages, lastReaderState ?: it.state)
lastReaderState = null
onPagesChanged(it.pages, restoredState ?: it.state)
restoredState = null
}
}
override fun onPause() {
super.onPause()
viewModel.saveCurrentState(getCurrentState())
}
override fun onDestroyView() {
lastReaderState = getCurrentState()
stateToSave = getCurrentState()
super.onDestroyView()
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
getCurrentState()?.let {
lastReaderState = it
stateToSave = it
}
outState.putParcelable(KEY_STATE, lastReaderState)
outState.putParcelable(KEY_STATE, stateToSave)
}
override fun onWindowInsetsChanged(insets: Insets) = Unit

View File

@@ -21,6 +21,7 @@ abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
init {
setHasStableIds(true)
stateRestorationPolicy = StateRestorationPolicy.PREVENT
}
override fun onBindViewHolder(holder: H, position: Int) {

View File

@@ -12,7 +12,9 @@ import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.pager.BaseReader
import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.reader.ui.pager.standard.PagerReaderFragment
import org.koitharu.kotatsu.utils.ext.*
import kotlin.math.absoluteValue
class ReversedReaderFragment : BaseReader<FragmentReaderStandardBinding>() {
@@ -58,7 +60,10 @@ class ReversedReaderFragment : BaseReader<FragmentReaderStandardBinding>() {
}
override fun switchPageTo(position: Int, smooth: Boolean) {
binding.pager.setCurrentItem(reversed(position), smooth)
binding.pager.setCurrentItem(
reversed(position),
smooth && (binding.pager.currentItem - position).absoluteValue < PagerReaderFragment.SMOOTH_SCROLL_LIMIT
)
}
override fun onPagesChanged(pages: List<ReaderPage>, pendingState: ReaderState?) {

View File

@@ -13,6 +13,7 @@ import org.koitharu.kotatsu.reader.ui.pager.BaseReader
import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.utils.ext.*
import kotlin.math.absoluteValue
class PagerReaderFragment : BaseReader<FragmentReaderStandardBinding>() {
@@ -78,7 +79,10 @@ class PagerReaderFragment : BaseReader<FragmentReaderStandardBinding>() {
}
override fun switchPageTo(position: Int, smooth: Boolean) {
binding.pager.setCurrentItem(position, smooth)
binding.pager.setCurrentItem(
position,
smooth && (binding.pager.currentItem - position).absoluteValue < SMOOTH_SCROLL_LIMIT
)
}
override fun getCurrentState(): ReaderState? = bindingOrNull()?.run {
@@ -94,4 +98,9 @@ class PagerReaderFragment : BaseReader<FragmentReaderStandardBinding>() {
private fun notifyPageChanged(page: Int) {
viewModel.onCurrentPageChanged(page)
}
companion object {
const val SMOOTH_SCROLL_LIMIT = 3
}
}

View File

@@ -10,6 +10,7 @@ class WebtoonImageView @JvmOverloads constructor(context: Context, attr: Attribu
SubsamplingScaleImageView(context, attr) {
private val ct = PointF()
private val displayHeight = resources.displayMetrics.heightPixels
private var scrollPos = 0
private var scrollRange = SCROLL_UNKNOWN
@@ -46,6 +47,14 @@ class WebtoonImageView @JvmOverloads constructor(context: Context, attr: Attribu
super.recycle()
}
override fun getSuggestedMinimumHeight(): Int {
var desiredHeight = super.getSuggestedMinimumHeight()
if (sHeight == 0 && desiredHeight < displayHeight) {
desiredHeight = displayHeight
}
return desiredHeight
}
private fun scrollToInternal(pos: Int) {
scrollPos = pos
ct.set(sWidth / 2f, (height / 2f + pos.toFloat()) / minScale)

View File

@@ -88,10 +88,6 @@ class WebtoonReaderFragment : BaseReader<FragmentReaderWebtoonBinding>() {
}
override fun switchPageTo(position: Int, smooth: Boolean) {
if (smooth) {
binding.recyclerView.smoothScrollToPosition(position)
} else {
binding.recyclerView.firstItem = position
}
binding.recyclerView.firstItem = position
}
}

View File

@@ -9,7 +9,7 @@ import org.koitharu.kotatsu.remotelist.ui.RemoteListViewModel
val remoteListModule
get() = module {
viewModel { (source: MangaSource) ->
RemoteListViewModel(get(named(source)), get())
viewModel { source ->
RemoteListViewModel(get(named(source.get<MangaSource>())), get())
}
}

View File

@@ -1,21 +1,16 @@
package org.koitharu.kotatsu.remotelist.ui
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaFilter
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.search.ui.SearchActivity
import org.koitharu.kotatsu.utils.ext.parcelableArgument
import org.koitharu.kotatsu.utils.ext.withArgs
class RemoteListFragment : MangaListFragment() {
override val viewModel by viewModel<RemoteListViewModel> {
override val viewModel by viewModel<RemoteListViewModel>(mode = LazyThreadSafetyMode.NONE) {
parametersOf(source)
}
@@ -25,7 +20,7 @@ class RemoteListFragment : MangaListFragment() {
viewModel.loadNextPage()
}
override fun getTitle(): CharSequence? {
override fun getTitle(): CharSequence {
return source.title
}
@@ -34,19 +29,6 @@ class RemoteListFragment : MangaListFragment() {
super.onFilterChanged(filter)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.opt_remote, menu)
super.onCreateOptionsMenu(menu, inflater)
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.action_search_internal -> {
context?.startActivity(SearchActivity.newIntent(requireContext(), source, null))
true
}
else -> super.onOptionsItemSelected(item)
}
companion object {
private const val ARG_SOURCE = "provider"

View File

@@ -1,22 +1,28 @@
package org.koitharu.kotatsu.search
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.qualifier.named
import org.koin.dsl.module
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider
import org.koitharu.kotatsu.search.ui.SearchViewModel
import org.koitharu.kotatsu.search.ui.global.GlobalSearchViewModel
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
val searchModule
get() = module {
single { MangaSearchRepository(get()) }
single { MangaSearchRepository(get(), get(), androidContext(), get()) }
viewModel { (source: MangaSource, query: String) ->
SearchViewModel(get(named(source)), query, get())
factory { MangaSuggestionsProvider.createSuggestions(androidContext()) }
viewModel { params ->
SearchViewModel(get(named(params.get<MangaSource>(0))), params[1], get())
}
viewModel { (query: String) ->
GlobalSearchViewModel(query, get(), get())
viewModel { query ->
GlobalSearchViewModel(query.get(), get(), get())
}
viewModel { SearchSuggestionViewModel(get()) }
}

View File

@@ -1,40 +1,126 @@
package org.koitharu.kotatsu.search.domain
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import android.annotation.SuppressLint
import android.app.SearchManager
import android.content.Context
import android.provider.SearchRecentSuggestions
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.base.domain.MangaProviderFactory
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.SortOrder
import org.koitharu.kotatsu.core.prefs.AppSettings
import java.util.*
import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider
import org.koitharu.kotatsu.utils.ext.levenshteinDistance
class MangaSearchRepository(private val settings: AppSettings) {
class MangaSearchRepository(
private val settings: AppSettings,
private val db: MangaDatabase,
private val context: Context,
private val recentSuggestions: SearchRecentSuggestions,
) {
fun globalSearch(query: String, batchSize: Int = 4): Flow<List<Manga>> = flow {
val sources = MangaProviderFactory.getSources(settings, includeHidden = false)
val lists = EnumMap<MangaSource, List<Manga>>(MangaSource::class.java)
var i = 0
while (true) {
var isEmitted = false
for (source in sources) {
val list = lists.getOrPut(source) {
try {
source.repository.getList(0, query, SortOrder.POPULARITY)
} catch (e: Throwable) {
e.printStackTrace()
emptyList<Manga>()
fun globalSearch(query: String, concurrency: Int = DEFAULT_CONCURRENCY): Flow<Manga> =
MangaProviderFactory.getSources(settings, includeHidden = false).asFlow()
.flatMapMerge(concurrency) { source ->
runCatching {
source.repository.getList(0, query, SortOrder.POPULARITY)
}.getOrElse {
emptyList()
}.asFlow()
}.filter {
match(it, query)
}
suspend fun getMangaSuggestion(query: String, limit: Int, source: MangaSource?): List<Manga> {
if (query.isEmpty()) {
return emptyList()
}
return if (source != null) {
db.mangaDao.searchByTitle("%$query%", source.name, limit)
} else {
db.mangaDao.searchByTitle("%$query%", limit)
}.map { it.toManga() }
.sortedBy { x -> x.title.levenshteinDistance(query) }
}
suspend fun getQuerySuggestion(
query: String,
limit: Int,
): List<String> = withContext(Dispatchers.IO) {
context.contentResolver.query(
MangaSuggestionsProvider.QUERY_URI,
SUGGESTION_PROJECTION,
"${SearchManager.SUGGEST_COLUMN_QUERY} LIKE ?",
arrayOf("%$query%"),
"date DESC"
)?.use { cursor ->
val count = minOf(cursor.count, limit)
if (count == 0) {
return@withContext emptyList()
}
val result = ArrayList<String>(count)
if (cursor.moveToFirst()) {
val index = cursor.getColumnIndexOrThrow(SearchManager.SUGGEST_COLUMN_QUERY)
do {
result += cursor.getString(index)
} while (currentCoroutineContext().isActive && cursor.moveToNext())
}
result
}.orEmpty()
}
fun saveSearchQuery(query: String) {
recentSuggestions.saveRecentQuery(query, null)
}
suspend fun clearSearchHistory(): Unit = withContext(Dispatchers.IO) {
recentSuggestions.clearHistory()
}
suspend fun deleteSearchQuery(query: String) = withContext(Dispatchers.IO) {
context.contentResolver.delete(
MangaSuggestionsProvider.URI,
"display1 = ?",
arrayOf(query),
)
}
suspend fun getSearchHistoryCount(): Int = withContext(Dispatchers.IO) {
context.contentResolver.query(
MangaSuggestionsProvider.QUERY_URI,
SUGGESTION_PROJECTION,
null,
arrayOfNulls(1),
null
)?.use { cursor -> cursor.count } ?: 0
}
private companion object {
private val REGEX_SPACE = Regex("\\s+")
val SUGGESTION_PROJECTION = arrayOf(SearchManager.SUGGEST_COLUMN_QUERY)
@SuppressLint("DefaultLocale")
fun match(manga: Manga, query: String): Boolean {
val words = HashSet<String>()
words += manga.title.lowercase().split(REGEX_SPACE)
words += manga.altTitle?.lowercase()?.split(REGEX_SPACE).orEmpty()
val words2 = query.lowercase().split(REGEX_SPACE).toSet()
for (w in words) {
for (w2 in words2) {
val diff = w.levenshteinDistance(w2) / ((w.length + w2.length) / 2f)
if (diff < 0.5) {
return true
}
}
if (i < list.size) {
emit(list.subList(i, (i + batchSize).coerceAtMost(list.lastIndex)))
isEmitted = true
}
}
i += batchSize
if (!isEmitted) {
return@flow
}
return false
}
}
}

View File

@@ -4,49 +4,14 @@ import android.app.SearchManager
import android.content.ContentResolver
import android.content.Context
import android.content.SearchRecentSuggestionsProvider
import android.database.Cursor
import android.net.Uri
import android.provider.SearchRecentSuggestions
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.cursoradapter.widget.CursorAdapter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
class MangaSuggestionsProvider : SearchRecentSuggestionsProvider() {
init {
setupSuggestions(
AUTHORITY,
MODE
)
}
private class SearchSuggestionAdapter(context: Context, cursor: Cursor) : CursorAdapter(
context, cursor,
FLAG_REGISTER_CONTENT_OBSERVER
) {
override fun newView(context: Context, cursor: Cursor?, parent: ViewGroup?): View {
return LayoutInflater.from(context)
.inflate(R.layout.item_search_complete, parent, false)
}
override fun bindView(view: View, context: Context, cursor: Cursor) {
if (view !is TextView) return
view.text = cursor.getString(cursor.getColumnIndex(SearchManager.SUGGEST_COLUMN_QUERY))
}
override fun convertToString(cursor: Cursor?): CharSequence {
return cursor?.getString(cursor.getColumnIndex(SearchManager.SUGGEST_COLUMN_QUERY))
.orEmpty()
}
setupSuggestions(AUTHORITY, MODE)
}
companion object {
@@ -54,65 +19,16 @@ class MangaSuggestionsProvider : SearchRecentSuggestionsProvider() {
private const val AUTHORITY = "${BuildConfig.APPLICATION_ID}.MangaSuggestionsProvider"
private const val MODE = DATABASE_MODE_QUERIES
private val uri = Uri.Builder()
fun createSuggestions(context: Context): SearchRecentSuggestions {
return SearchRecentSuggestions(context, AUTHORITY, MODE)
}
val QUERY_URI: Uri = Uri.Builder()
.scheme(ContentResolver.SCHEME_CONTENT)
.authority(AUTHORITY)
.appendPath(SearchManager.SUGGEST_URI_PATH_QUERY)
.build()
private val projection = arrayOf("_id", SearchManager.SUGGEST_COLUMN_QUERY)
fun saveQueryAsync(context: Context, query: String) {
GlobalScope.launch(Dispatchers.IO) {
saveQuery(context, query)
}
}
fun saveQuery(context: Context, query: String) {
runCatching {
SearchRecentSuggestions(
context,
AUTHORITY,
MODE
).saveRecentQuery(query, null)
}.onFailure {
if (BuildConfig.DEBUG) {
it.printStackTrace()
}
}
}
suspend fun clearHistory(context: Context) = withContext(Dispatchers.IO) {
SearchRecentSuggestions(
context,
AUTHORITY,
MODE
).clearHistory()
}
suspend fun getItemsCount(context: Context) = withContext(Dispatchers.IO) {
getCursor(context)?.use { it.count } ?: 0
}
private fun getCursor(context: Context): Cursor? {
return context.contentResolver?.query(uri, projection, null, arrayOf(""), null)
}
@Deprecated("Need async implementation")
fun getSuggestionAdapter(context: Context): CursorAdapter? = getCursor(
context
)?.let { cursor ->
SearchSuggestionAdapter(context, cursor).also {
it.setFilterQueryProvider { q ->
context.contentResolver?.query(
uri,
projection,
" ?",
arrayOf(q?.toString().orEmpty()),
null
)
}
}
}
val URI: Uri = Uri.parse("content://$AUTHORITY/suggestions")
}
}

View File

@@ -7,14 +7,20 @@ import android.os.Parcelable
import androidx.appcompat.widget.SearchView
import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
import androidx.fragment.app.commit
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.databinding.ActivitySearchBinding
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
import org.koitharu.kotatsu.utils.ext.showKeyboard
class SearchActivity : BaseActivity<ActivitySearchBinding>(), SearchView.OnQueryTextListener {
private val searchSuggestionViewModel by viewModel<SearchSuggestionViewModel>(
mode = LazyThreadSafetyMode.NONE
)
private lateinit var source: MangaSource
override fun onCreate(savedInstanceState: Bundle?) {
@@ -28,8 +34,6 @@ class SearchActivity : BaseActivity<ActivitySearchBinding>(), SearchView.OnQuery
supportActionBar?.setDisplayHomeAsUpEnabled(true)
with(binding.searchView) {
queryHint = getString(R.string.search_on_s, source.title)
suggestionsAdapter = MangaSuggestionsProvider.getSuggestionAdapter(this@SearchActivity)
setOnSuggestionListener(SearchHelper.SuggestionListener(this))
setOnQueryTextListener(this@SearchActivity)
if (query.isNullOrBlank()) {
@@ -41,11 +45,6 @@ class SearchActivity : BaseActivity<ActivitySearchBinding>(), SearchView.OnQuery
}
}
override fun onDestroy() {
binding.searchView.suggestionsAdapter.changeCursor(null) //close cursor
super.onDestroy()
}
override fun onWindowInsetsChanged(insets: Insets) {
binding.toolbar.updatePadding(
top = insets.top,
@@ -55,19 +54,20 @@ class SearchActivity : BaseActivity<ActivitySearchBinding>(), SearchView.OnQuery
}
override fun onQueryTextSubmit(query: String?): Boolean {
return if (!query.isNullOrBlank()) {
title = query
supportFragmentManager
.beginTransaction()
.replace(R.id.container, SearchFragment.newInstance(source, query))
.commit()
binding.searchView.clearFocus()
MangaSuggestionsProvider.saveQueryAsync(applicationContext, query)
true
} else false
val q = query?.trim()
if (q.isNullOrEmpty()) {
return false
}
title = query
supportFragmentManager.commit {
replace(R.id.container, SearchFragment.newInstance(source, q))
}
binding.searchView.clearFocus()
searchSuggestionViewModel.saveQuery(q)
return true
}
override fun onQueryTextChange(newText: String?) = false
override fun onQueryTextChange(newText: String?): Boolean = false
companion object {

View File

@@ -10,7 +10,7 @@ import org.koitharu.kotatsu.utils.ext.withArgs
class SearchFragment : MangaListFragment() {
override val viewModel by viewModel<SearchViewModel> {
override val viewModel by viewModel<SearchViewModel>(mode = LazyThreadSafetyMode.NONE) {
parametersOf(source, query)
}

View File

@@ -1,53 +0,0 @@
package org.koitharu.kotatsu.search.ui
import android.app.SearchManager
import android.content.Context
import android.database.Cursor
import android.view.MenuItem
import androidx.appcompat.widget.SearchView
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.search.ui.global.GlobalSearchActivity
import java.io.Closeable
object SearchHelper {
fun setupSearchView(menuItem: MenuItem): Closeable? {
val view = menuItem.actionView as? SearchView ?: return null
val context = view.context
val adapter = MangaSuggestionsProvider.getSuggestionAdapter(context)
view.queryHint = context.getString(R.string.search_manga)
view.suggestionsAdapter = adapter
view.setOnQueryTextListener(QueryListener(context))
view.setOnSuggestionListener(SuggestionListener(view))
return adapter?.cursor
}
private class QueryListener(private val context: Context) :
SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
return if (!query.isNullOrBlank()) {
context.startActivity(GlobalSearchActivity.newIntent(context, query.trim()))
MangaSuggestionsProvider.saveQueryAsync(context.applicationContext, query)
true
} else false
}
override fun onQueryTextChange(newText: String?) = false
}
class SuggestionListener(private val view: SearchView) :
SearchView.OnSuggestionListener {
override fun onSuggestionSelect(position: Int) = false
override fun onSuggestionClick(position: Int): Boolean {
val query = runCatching {
val c = view.suggestionsAdapter.getItem(position) as? Cursor
c?.getString(c.getColumnIndex(SearchManager.SUGGEST_COLUMN_QUERY))
}.getOrNull() ?: return false
view.setQuery(query, true)
return true
}
}
}

View File

@@ -9,7 +9,7 @@ import org.koitharu.kotatsu.utils.ext.withArgs
class GlobalSearchFragment : MangaListFragment() {
override val viewModel by viewModel<GlobalSearchViewModel> {
override val viewModel by viewModel<GlobalSearchViewModel>(mode = LazyThreadSafetyMode.NONE) {
parametersOf(query)
}

View File

@@ -62,8 +62,8 @@ class GlobalSearchViewModel(
.catch { e ->
listError.value = e
isLoading.postValue(false)
}.filterNot { x -> x.isEmpty() }
.onStart {
}.onStart {
mangaList.value = null
listError.value = null
isLoading.postValue(true)
hasNextPage.value = true
@@ -75,7 +75,7 @@ class GlobalSearchViewModel(
}.onFirst {
isLoading.postValue(false)
}.onEach {
mangaList.value = mangaList.value?.plus(it) ?: it
mangaList.value = mangaList.value?.plus(it) ?: listOf(it)
}.launchIn(viewModelScope + Dispatchers.Default)
}
}

View File

@@ -0,0 +1,57 @@
package org.koitharu.kotatsu.search.ui.suggestion
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
import androidx.recyclerview.widget.ItemTouchHelper
import org.koin.android.ext.android.get
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.databinding.FragmentSearchSuggestionBinding
import org.koitharu.kotatsu.search.ui.suggestion.adapter.SearchSuggestionAdapter
class SearchSuggestionFragment : BaseFragment<FragmentSearchSuggestionBinding>(),
SearchSuggestionItemCallback.SuggestionItemListener {
private val viewModel by sharedViewModel<SearchSuggestionViewModel>()
override fun onInflateView(
inflater: LayoutInflater,
container: ViewGroup?,
) = FragmentSearchSuggestionBinding.inflate(inflater, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val adapter = SearchSuggestionAdapter(
coil = get(),
lifecycleOwner = viewLifecycleOwner,
listener = requireActivity() as SearchSuggestionListener,
)
binding.root.adapter = adapter
viewModel.suggestion.observe(viewLifecycleOwner) {
adapter.items = it
}
ItemTouchHelper(SearchSuggestionItemCallback(this))
.attachToRecyclerView(binding.root)
}
override fun onWindowInsetsChanged(insets: Insets) {
binding.root.updatePadding(
left = insets.left,
right = insets.right,
bottom = insets.bottom,
)
}
override fun onRemoveQuery(query: String) {
viewModel.deleteQuery(query)
}
companion object {
fun newInstance() = SearchSuggestionFragment()
}
}

View File

@@ -0,0 +1,42 @@
package org.koitharu.kotatsu.search.ui.suggestion
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.search.ui.suggestion.adapter.SearchSuggestionAdapter
import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem
import org.koitharu.kotatsu.utils.ext.getItem
class SearchSuggestionItemCallback(
private val listener: SuggestionItemListener,
) : ItemTouchHelper.Callback() {
private val movementFlags = makeMovementFlags(
0,
ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
)
override fun getMovementFlags(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
): Int = if (viewHolder.itemViewType == SearchSuggestionAdapter.ITEM_TYPE_QUERY) {
movementFlags
} else {
0
}
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder,
): Boolean = false
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
val item = viewHolder.getItem<SearchSuggestionItem.RecentQuery>() ?: return
listener.onRemoveQuery(item.query)
}
interface SuggestionItemListener {
fun onRemoveQuery(query: String)
}
}

View File

@@ -0,0 +1,14 @@
package org.koitharu.kotatsu.search.ui.suggestion
import org.koitharu.kotatsu.core.model.Manga
interface SearchSuggestionListener {
fun onMangaClick(manga: Manga)
fun onQueryClick(query: String, submit: Boolean)
fun onQueryChanged(query: String)
fun onClearSearchHistory()
}

View File

@@ -0,0 +1,95 @@
package org.koitharu.kotatsu.search.ui.suggestion
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem
class SearchSuggestionViewModel(
private val repository: MangaSearchRepository,
) : BaseViewModel() {
private val query = MutableStateFlow("")
private val source = MutableStateFlow<MangaSource?>(null)
private val isLocalSearch = MutableStateFlow(false)
private var suggestionJob: Job? = null
val suggestion = MutableLiveData<List<SearchSuggestionItem>>()
init {
setupSuggestion()
}
fun onQueryChanged(newQuery: String) {
query.value = newQuery
}
fun onSourceChanged(newSource: MangaSource?) {
source.value = newSource
}
fun saveQuery(query: String) {
repository.saveSearchQuery(query)
}
fun getLocalSearchSource(): MangaSource? {
return source.value?.takeIf { isLocalSearch.value }
}
fun clearSearchHistory() {
launchJob {
repository.clearSearchHistory()
setupSuggestion()
}
}
fun deleteQuery(query: String) {
launchJob {
repository.deleteSearchQuery(query)
setupSuggestion()
}
}
private fun setupSuggestion() {
suggestionJob?.cancel()
suggestionJob = combine(
query
.debounce(DEBOUNCE_TIMEOUT)
.mapLatest { q ->
q to repository.getQuerySuggestion(q, MAX_QUERY_ITEMS)
},
source,
isLocalSearch
) { (q, queries), src, srcOnly ->
val result = ArrayList<SearchSuggestionItem>(MAX_SUGGESTION_ITEMS)
if (src != null) {
result += SearchSuggestionItem.Header(src, isLocalSearch)
}
if (q.length >= SEARCH_THRESHOLD) {
repository.getMangaSuggestion(q, MAX_MANGA_ITEMS, src.takeIf { srcOnly })
.mapTo(result) {
SearchSuggestionItem.MangaItem(it)
}
}
queries.mapTo(result) { SearchSuggestionItem.RecentQuery(it) }
result
}.onEach {
suggestion.postValue(it)
}.launchIn(viewModelScope + Dispatchers.Default)
}
private companion object {
const val DEBOUNCE_TIMEOUT = 500L
const val SEARCH_THRESHOLD = 3
const val MAX_MANGA_ITEMS = 3
const val MAX_QUERY_ITEMS = 120
const val MAX_SUGGESTION_ITEMS = MAX_MANGA_ITEMS + MAX_QUERY_ITEMS + 1
}
}

View File

@@ -0,0 +1,51 @@
package org.koitharu.kotatsu.search.ui.suggestion
import android.view.MenuItem
import androidx.appcompat.widget.SearchView
import org.koitharu.kotatsu.R
class SearchUI(
private val searchView: SearchView,
listener: SearchSuggestionListener,
hint: String? = null,
) {
init {
val context = searchView.context
searchView.queryHint = hint ?: context.getString(R.string.search_manga)
searchView.setOnQueryTextListener(QueryListener(listener))
}
var query: String
get() = searchView.query.toString()
set(value) {
searchView.setQuery(value, false)
}
private class QueryListener(
private val listener: SearchSuggestionListener,
) : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
return if (!query.isNullOrBlank()) {
listener.onQueryClick(query.trim(), submit = true)
true
} else false
}
override fun onQueryTextChange(newText: String?): Boolean {
listener.onQueryChanged(newText?.trim().orEmpty())
return true
}
}
companion object {
fun from(
menuItem: MenuItem,
listener: SearchSuggestionListener,
): SearchUI? = (menuItem.actionView as? SearchView)?.let {
SearchUI(it, listener)
}
}
}

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