Compare commits

..

52 Commits
v1.0 ... v1.1.1

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
160 changed files with 3147 additions and 866 deletions

View File

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

View File

@@ -31,5 +31,10 @@
<option name="name" value="maven2" /> <option name="name" value="maven2" />
<option name="url" value="https://dl.bintray.com/kotlin/kotlin-eap" /> <option name="url" value="https://dl.bintray.com/kotlin/kotlin-eap" />
</remote-repository> </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> </component>
</project> </project>

38
.idea/misc.xml generated
View File

@@ -1,38 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DesignSurface">
<option name="filePathToZoomLevelMap">
<map>
<entry key="../../../../../../layout/custom_preview.xml" value="0.1" />
<entry key="../../../../../../opt/usr/android-sdk/platforms/android-30/data/res/drawable/list_divider_material.xml" value="0.28512820512820514" />
<entry key="../../../../../../opt/usr/android-sdk/platforms/android-30/data/res/layout/simple_dropdown_item_1line.xml" value="0.24739583333333334" />
<entry key="app/src/main/res/drawable/tab_indicator.xml" value="0.28512820512820514" />
<entry key="app/src/main/res/drawable/tabs_background.xml" value="0.28512820512820514" />
<entry key="app/src/main/res/layout-w600dp/fragment_details.xml" value="0.14583333333333334" />
<entry key="app/src/main/res/layout-w600dp/fragment_list.xml" value="0.14635416666666667" />
<entry key="app/src/main/res/layout/dialog_favorite_categories.xml" value="0.2601851851851852" />
<entry key="app/src/main/res/layout/dialog_list_mode.xml" value="0.2601851851851852" />
<entry key="app/src/main/res/layout/fragment_chapters.xml" value="0.24739583333333334" />
<entry key="app/src/main/res/layout/fragment_details.xml" value="0.26145833333333335" />
<entry key="app/src/main/res/layout/fragment_favourites.xml" value="0.26296296296296295" />
<entry key="app/src/main/res/layout/fragment_feed.xml" value="0.2601851851851852" />
<entry key="app/src/main/res/layout/fragment_list.xml" value="0.2601851851851852" />
<entry key="app/src/main/res/layout/item_branch.xml" value="0.24739583333333334" />
<entry key="app/src/main/res/layout/item_branch_dropdown.xml" value="0.25743589743589745" />
<entry key="app/src/main/res/layout/item_category_checkable.xml" value="0.2601851851851852" />
<entry key="app/src/main/res/layout/item_manga_grid.xml" value="0.26042632066728455" />
<entry key="app/src/main/res/layout/item_manga_list_details.xml" value="0.2601851851851852" />
<entry key="app/src/main/res/layout/item_page_thumb.xml" value="0.2601851851851852" />
<entry key="app/src/main/res/layout/item_recent.xml" value="0.2601851851851852" />
<entry key="app/src/main/res/layout/sheet_pages.xml" value="0.2601851851851852" />
<entry key="app/src/main/res/menu/popup_category.xml" value="0.2601851851851852" />
</map>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" default="true" project-jdk-name="11" 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,9 +6,14 @@ Kotatsu is a free and open source manga reader for Android.
### Download ### 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 ### Main Features

View File

@@ -5,8 +5,6 @@ plugins {
id 'kotlin-parcelize' id 'kotlin-parcelize'
} }
def gitCommits = 'git rev-list --count HEAD'.execute([], rootDir).text.trim().toInteger()
android { android {
compileSdkVersion 30 compileSdkVersion 30
buildToolsVersion '30.0.3' buildToolsVersion '30.0.3'
@@ -15,8 +13,8 @@ android {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 30 targetSdkVersion 30
versionCode gitCommits versionCode 366
versionName '1.0' versionName '1.1.1'
kapt { kapt {
arguments { arguments {
@@ -56,18 +54,19 @@ tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all {
jvmTarget = JavaVersion.VERSION_1_8.toString() jvmTarget = JavaVersion.VERSION_1_8.toString()
freeCompilerArgs += [ freeCompilerArgs += [
'-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi', '-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
'-Xopt-in=kotlinx.coroutines.FlowPreview',
'-Xopt-in=org.koin.core.component.KoinApiExtension' '-Xopt-in=org.koin.core.component.KoinApiExtension'
] ]
} }
} }
dependencies { dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar']) implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.2' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.2' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0'
implementation 'androidx.core:core-ktx:1.5.0-rc01' implementation 'androidx.core:core-ktx:1.5.0'
implementation 'androidx.activity:activity-ktx:1.2.2' implementation 'androidx.activity:activity-ktx:1.2.3'
implementation 'androidx.fragment:fragment-ktx:1.3.2' implementation 'androidx.fragment:fragment-ktx:1.3.5'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1' implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1'
implementation 'androidx.lifecycle:lifecycle-viewmodel-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-livedata-ktx:2.3.1'
@@ -75,7 +74,7 @@ dependencies {
implementation 'androidx.lifecycle:lifecycle-process:2.3.1' implementation 'androidx.lifecycle:lifecycle-process:2.3.1'
implementation 'androidx.constraintlayout:constraintlayout:2.0.4' implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.2.0-rc01' implementation 'androidx.recyclerview:recyclerview:1.2.1'
implementation 'androidx.viewpager2:viewpager2:1.1.0-alpha01' implementation 'androidx.viewpager2:viewpager2:1.1.0-alpha01'
implementation 'androidx.preference:preference-ktx:1.1.1' implementation 'androidx.preference:preference-ktx:1.1.1'
implementation 'androidx.work:work-runtime-ktx:2.5.0' implementation 'androidx.work:work-runtime-ktx:2.5.0'
@@ -83,26 +82,25 @@ dependencies {
//noinspection LifecycleAnnotationProcessorWithJava8 //noinspection LifecycleAnnotationProcessorWithJava8
kapt 'androidx.lifecycle:lifecycle-compiler:2.3.1' kapt 'androidx.lifecycle:lifecycle-compiler:2.3.1'
implementation 'androidx.room:room-runtime:2.2.6' implementation 'androidx.room:room-runtime:2.3.0'
implementation 'androidx.room:room-ktx:2.2.6' implementation 'androidx.room:room-ktx:2.3.0'
kapt 'androidx.room:room-compiler:2.2.6' 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 'com.squareup.okio:okio:2.10.0'
implementation 'org.jsoup:jsoup:1.13.1' implementation 'org.jsoup:jsoup:1.13.1'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.0' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.0'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.0' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.0'
implementation 'org.koin:koin-android:2.2.2' implementation 'io.insert-koin:koin-android:3.1.0'
implementation 'org.koin:koin-androidx-viewmodel:2.2.2' implementation 'io.coil-kt:coil-base:1.2.2'
implementation 'io.coil-kt:coil-base:1.1.1'
implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0' 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.2' testImplementation 'junit:junit:4.13.2'
testImplementation 'org.json:json:20201115' testImplementation 'org.json:json:20210307'
testImplementation 'org.koin:koin-test:2.2.2' testImplementation 'io.insert-koin:koin-test-junit4:3.1.0'
} }

View File

@@ -78,6 +78,10 @@
android:label="@string/search" /> android:label="@string/search" />
<activity <activity
android:name="org.koitharu.kotatsu.main.ui.protect.ProtectActivity" android:name="org.koitharu.kotatsu.main.ui.protect.ProtectActivity"
android:noHistory="true"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".settings.protect.ProtectSetupActivity"
android:windowSoftInputMode="adjustResize" /> android:windowSoftInputMode="adjustResize" />
<service <service
@@ -127,9 +131,11 @@
android:resource="@xml/widget_recent" /> android:resource="@xml/widget_recent" />
</receiver> </receiver>
<meta-data android:name="android.webkit.WebView.EnableSafeBrowsing" <meta-data
android:name="android.webkit.WebView.EnableSafeBrowsing"
android:value="false" /> android:value="false" />
<meta-data android:name="android.webkit.WebView.MetricsOptOut" <meta-data
android:name="android.webkit.WebView.MetricsOptOut"
android:value="true" /> android:value="true" />
</application> </application>

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.AppCrashHandler
import org.koitharu.kotatsu.core.ui.uiModule import org.koitharu.kotatsu.core.ui.uiModule
import org.koitharu.kotatsu.details.detailsModule import org.koitharu.kotatsu.details.detailsModule
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.favouritesModule import org.koitharu.kotatsu.favourites.favouritesModule
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.history.historyModule import org.koitharu.kotatsu.history.historyModule
import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.local.localModule import org.koitharu.kotatsu.local.localModule
import org.koitharu.kotatsu.main.mainModule import org.koitharu.kotatsu.main.mainModule
import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
import org.koitharu.kotatsu.reader.readerModule import org.koitharu.kotatsu.reader.readerModule
import org.koitharu.kotatsu.remotelist.remoteListModule import org.koitharu.kotatsu.remotelist.remoteListModule
import org.koitharu.kotatsu.search.searchModule import org.koitharu.kotatsu.search.searchModule
@@ -36,28 +35,15 @@ class KotatsuApp : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy( enableStrictMode()
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()
)
} }
initKoin() initKoin()
Thread.setDefaultUncaughtExceptionHandler(AppCrashHandler(applicationContext)) Thread.setDefaultUncaughtExceptionHandler(AppCrashHandler(applicationContext))
AppCompatDelegate.setDefaultNightMode(get<AppSettings>().theme) AppCompatDelegate.setDefaultNightMode(get<AppSettings>().theme)
registerActivityLifecycleCallbacks(get<AppProtectHelper>())
val widgetUpdater = WidgetUpdater(applicationContext) val widgetUpdater = WidgetUpdater(applicationContext)
FavouritesRepository.subscribe(widgetUpdater) widgetUpdater.subscribeToFavourites(get())
HistoryRepository.subscribe(widgetUpdater) widgetUpdater.subscribeToHistory(get())
} }
private fun initKoin() { 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 private val cookieJar: CookieJar
) : KoinComponent { ) : KoinComponent {
suspend fun httpGet(url: String): Response { suspend fun httpGet(url: String, headers: Headers? = null): Response {
val request = Request.Builder() val request = Request.Builder()
.get() .get()
.url(url) .url(url)
if (headers != null) {
request.headers(headers)
}
return okHttp.newCall(request.build()).await() return okHttp.newCall(request.build()).await()
} }

View File

@@ -8,7 +8,6 @@ import androidx.annotation.CallSuper
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import com.google.android.material.dialog.MaterialAlertDialogBuilder
abstract class AlertDialogFragment<B : ViewBinding> : DialogFragment() { abstract class AlertDialogFragment<B : ViewBinding> : DialogFragment() {
@@ -21,7 +20,7 @@ abstract class AlertDialogFragment<B : ViewBinding> : DialogFragment() {
val inflater = activity?.layoutInflater ?: LayoutInflater.from(requireContext()) val inflater = activity?.layoutInflater ?: LayoutInflater.from(requireContext())
val binding = onInflateView(inflater, null) val binding = onInflateView(inflater, null)
viewBinding = binding viewBinding = binding
return MaterialAlertDialogBuilder(requireContext(), theme) return AlertDialog.Builder(requireContext(), theme)
.setView(binding.root) .setView(binding.root)
.also(::onBuildDialog) .also(::onBuildDialog)
.create() .create()

View File

@@ -15,6 +15,8 @@ import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.* import androidx.core.view.*
import androidx.viewbinding.ViewBinding 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.koin.android.ext.android.get
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@@ -27,11 +29,12 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(), OnApplyWindo
protected lateinit var binding: B protected lateinit var binding: B
private set private set
protected val exceptionResolver by lazy(LazyThreadSafetyMode.NONE) { protected val exceptionResolver by lazy(LazyThreadSafetyMode.NONE) {
ExceptionResolver(this, supportFragmentManager) ExceptionResolver(this, supportFragmentManager)
} }
private var lastInsets: Insets = Insets.NONE
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
if (get<AppSettings>().isAmoledTheme) { if (get<AppSettings>().isAmoledTheme) {
setTheme(R.style.AppTheme_Amoled) setTheme(R.style.AppTheme_Amoled)
@@ -56,11 +59,23 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(), OnApplyWindo
this.binding = binding this.binding = binding
super.setContentView(binding.root) super.setContentView(binding.root)
(binding.root.findViewById<View>(R.id.toolbar) as? Toolbar)?.let(this::setSupportActionBar) (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) 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 { 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 return insets
} }

View File

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

View File

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

View File

@@ -6,7 +6,6 @@ import android.view.LayoutInflater
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.databinding.DialogCheckboxBinding import org.koitharu.kotatsu.databinding.DialogCheckboxBinding
class CheckBoxAlertDialog private constructor(private val delegate: AlertDialog) : class CheckBoxAlertDialog private constructor(private val delegate: AlertDialog) :
@@ -18,7 +17,7 @@ class CheckBoxAlertDialog private constructor(private val delegate: AlertDialog)
private val binding = DialogCheckboxBinding.inflate(LayoutInflater.from(context)) private val binding = DialogCheckboxBinding.inflate(LayoutInflater.from(context))
private val delegate = MaterialAlertDialogBuilder(context) private val delegate = AlertDialog.Builder(context)
.setView(binding.root) .setView(binding.root)
fun setTitle(@StringRes titleResId: Int): Builder { fun setTitle(@StringRes titleResId: Int): Builder {

View File

@@ -24,7 +24,7 @@ class StorageSelectDialog private constructor(private val delegate: AlertDialog)
class Builder(context: Context, defaultValue: File?, listener: OnStorageSelectListener) { class Builder(context: Context, defaultValue: File?, listener: OnStorageSelectListener) {
private val adapter = VolumesAdapter(context) private val adapter = VolumesAdapter(context)
private val delegate = MaterialAlertDialogBuilder(context) private val delegate = AlertDialog.Builder(context)
init { init {
if (adapter.isEmpty) { if (adapter.isEmpty) {

View File

@@ -19,7 +19,7 @@ class TextInputDialog private constructor(
private val binding = DialogInputBinding.inflate(LayoutInflater.from(context)) private val binding = DialogInputBinding.inflate(LayoutInflater.from(context))
private val delegate = MaterialAlertDialogBuilder(context) private val delegate = AlertDialog.Builder(context)
.setView(binding.root) .setView(binding.root)
fun setTitle(@StringRes titleResId: Int): Builder { fun setTitle(@StringRes titleResId: Int): Builder {

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) menuInflater.inflate(R.menu.opt_browser, menu)
return super.onCreateOptionsMenu(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 { 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) { override fun onPageFinished(webView: WebView, url: String) {
super.onPageFinished(webView, url) super.onPageFinished(webView, url)

View File

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

View File

@@ -83,6 +83,7 @@ class BackupRepository(private val db: MangaDatabase) {
jo.put("url", url) jo.put("url", url)
jo.put("public_url", publicUrl) jo.put("public_url", publicUrl)
jo.put("rating", rating) jo.put("rating", rating)
jo.put("nsfw", isNsfw)
jo.put("cover_url", coverUrl) jo.put("cover_url", coverUrl)
jo.put("large_cover_url", largeCoverUrl) jo.put("large_cover_url", largeCoverUrl)
jo.put("state", state) 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.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity import org.koitharu.kotatsu.favourites.data.FavouriteEntity
import org.koitharu.kotatsu.history.data.HistoryEntity 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.getStringOrNull
import org.koitharu.kotatsu.utils.ext.iterator import org.koitharu.kotatsu.utils.ext.iterator
import org.koitharu.kotatsu.utils.ext.map import org.koitharu.kotatsu.utils.ext.map
@@ -72,6 +73,7 @@ class RestoreRepository(private val db: MangaDatabase) {
url = json.getString("url"), url = json.getString("url"),
publicUrl = json.getStringOrNull("public_url").orEmpty(), publicUrl = json.getStringOrNull("public_url").orEmpty(),
rating = json.getDouble("rating").toFloat(), rating = json.getDouble("rating").toFloat(),
isNsfw = json.getBooleanOrDefault("nsfw", false),
coverUrl = json.getString("cover_url"), coverUrl = json.getString("cover_url"),
largeCoverUrl = json.getStringOrNull("large_cover_url"), largeCoverUrl = json.getStringOrNull("large_cover_url"),
state = json.getStringOrNull("state"), state = json.getStringOrNull("state"),

View File

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

View File

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

View File

@@ -13,6 +13,14 @@ abstract class MangaDao {
@Query("SELECT * FROM manga WHERE manga_id = :id") @Query("SELECT * FROM manga WHERE manga_id = :id")
abstract suspend fun find(id: Long): MangaWithTags? 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) @Insert(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun insert(manga: MangaEntity): Long abstract suspend fun insert(manga: MangaEntity): Long

View File

@@ -13,14 +13,15 @@ data class MangaEntity(
@PrimaryKey(autoGenerate = false) @PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id") val id: Long, @ColumnInfo(name = "manga_id") val id: Long,
@ColumnInfo(name = "title") val title: String, @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 = "url") val url: String,
@ColumnInfo(name = "public_url") val publicUrl: 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 = "cover_url") val coverUrl: String,
@ColumnInfo(name = "large_cover_url") val largeCoverUrl: String? = null, @ColumnInfo(name = "large_cover_url") val largeCoverUrl: String?,
@ColumnInfo(name = "state") val state: String? = null, @ColumnInfo(name = "state") val state: String?,
@ColumnInfo(name = "author") val author: String? = null, @ColumnInfo(name = "author") val author: String?,
@ColumnInfo(name = "source") val source: String @ColumnInfo(name = "source") val source: String
) { ) {
@@ -30,6 +31,7 @@ data class MangaEntity(
altTitle = this.altTitle, altTitle = this.altTitle,
state = this.state?.let { MangaState.valueOf(it) }, state = this.state?.let { MangaState.valueOf(it) },
rating = this.rating, rating = this.rating,
isNsfw = this.isNsfw,
url = this.url, url = this.url,
publicUrl = this.publicUrl, publicUrl = this.publicUrl,
coverUrl = this.coverUrl, coverUrl = this.coverUrl,
@@ -50,6 +52,7 @@ data class MangaEntity(
coverUrl = manga.coverUrl, coverUrl = manga.coverUrl,
altTitle = manga.altTitle, altTitle = manga.altTitle,
rating = manga.rating, rating = manga.rating,
isNsfw = manga.isNsfw,
state = manga.state?.name, state = manga.state?.name,
title = manga.title, title = manga.title,
author = manga.author 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 url: String, // relative url for internal use
val publicUrl: String, val publicUrl: String,
val rating: Float = NO_RATING, //normalized value [0..1] or -1 val rating: Float = NO_RATING, //normalized value [0..1] or -1
val isNsfw: Boolean = false,
val coverUrl: String, val coverUrl: String,
val largeCoverUrl: String? = null, val largeCoverUrl: String? = null,
val description: String? = null, //HTML val description: String? = null, //HTML

View File

@@ -30,7 +30,16 @@ enum class MangaSource(
// NUDEMOON("Nude-Moon", "ru", NudeMoonRepository::class.java), // NUDEMOON("Nude-Moon", "ru", NudeMoonRepository::class.java),
MANGAREAD("MangaRead", "en", MangareadRepository::class.java), MANGAREAD("MangaRead", "en", MangareadRepository::class.java),
REMANGA("Remanga", "ru", RemangaRepository::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) @get:Throws(NoBeanDefFoundException::class)
@Deprecated("") @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 -> suspend fun clear() = suspendCoroutine<Boolean> { continuation ->
cookieManager.removeAllCookies(continuation::resume) 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.core.qualifier.named
import org.koin.dsl.bind import org.koin.dsl.bind
import org.koin.dsl.module import org.koin.dsl.module
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.utils.CacheUtils import org.koitharu.kotatsu.utils.CacheUtils
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -22,6 +23,9 @@ val networkModule
cache(get(named(CacheUtils.QUALIFIER_HTTP))) cache(get(named(CacheUtils.QUALIFIER_HTTP)))
addInterceptor(UserAgentInterceptor()) addInterceptor(UserAgentInterceptor())
addInterceptor(CloudFlareInterceptor()) addInterceptor(CloudFlareInterceptor())
if (BuildConfig.DEBUG) {
addNetworkInterceptor(CurlLoggingInterceptor())
}
}.build() }.build()
} }
} }

View File

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

View File

@@ -24,4 +24,12 @@ val parserModule
factory<MangaRepository>(named(MangaSource.MANGAREAD)) { MangareadRepository(get()) } factory<MangaRepository>(named(MangaSource.MANGAREAD)) { MangareadRepository(get()) }
factory<MangaRepository>(named(MangaSource.REMANGA)) { RemangaRepository(get()) } factory<MangaRepository>(named(MangaSource.REMANGA)) { RemangaRepository(get()) }
factory<MangaRepository>(named(MangaSource.HENTAILIB)) { HentaiLibRepository(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

@@ -49,18 +49,17 @@ class MangareadRepository(
id = generateUid(href), id = generateUid(href),
url = href, url = href,
publicUrl = href.inContextOf(div), publicUrl = href.inContextOf(div),
coverUrl = div.selectFirst("img").attr("data-srcset") coverUrl = div.selectFirst("img").absUrl("src"),
.split(',').firstOrNull()?.substringBeforeLast(' ').orEmpty(),
title = summary.selectFirst("h3").text(), title = summary.selectFirst("h3").text(),
rating = div.selectFirst("span.total_votes")?.ownText() rating = div.selectFirst("span.total_votes")?.ownText()
?.toFloatOrNull()?.div(5f) ?: -1f, ?.toFloatOrNull()?.div(5f) ?: -1f,
tags = summary.selectFirst(".mg_genres").select("a").mapToSet { a -> tags = summary.selectFirst(".mg_genres")?.select("a")?.mapToSet { a ->
MangaTag( MangaTag(
key = a.attr("href").removeSuffix("/").substringAfterLast('/'), key = a.attr("href").removeSuffix("/").substringAfterLast('/'),
title = a.text(), title = a.text(),
source = MangaSource.MANGAREAD source = MangaSource.MANGAREAD
) )
}, }.orEmpty(),
author = summary.selectFirst(".mg_author")?.selectFirst("a")?.ownText(), author = summary.selectFirst(".mg_author")?.selectFirst("a")?.ownText(),
state = when (summary.selectFirst(".mg_status")?.selectFirst(".summary-content") state = when (summary.selectFirst(".mg_status")?.selectFirst(".summary-content")
?.ownText()?.trim()) { ?.ownText()?.trim()) {
@@ -148,7 +147,7 @@ class MangareadRepository(
?: throw ParseException("Root not found") ?: throw ParseException("Root not found")
return root.select("div.page-break").map { div -> return root.select("div.page-break").map { div ->
val img = div.selectFirst("img") val img = div.selectFirst("img")
val url = img.relUrl("data-src") val url = img.relUrl("src")
MangaPage( MangaPage(
id = generateUid(url), id = generateUid(url),
url = 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 defaultDomain = "remanga.org"
override val sortOrders: Set<SortOrder> = EnumSet.of( override val sortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.UPDATED,
SortOrder.POPULARITY, SortOrder.POPULARITY,
SortOrder.RATING, SortOrder.RATING,
SortOrder.ALPHABETICAL,
SortOrder.UPDATED,
SortOrder.NEWEST SortOrder.NEWEST
) )
@@ -162,7 +161,7 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
SortOrder.POPULARITY -> "-rating" SortOrder.POPULARITY -> "-rating"
SortOrder.RATING -> "-votes" SortOrder.RATING -> "-votes"
SortOrder.NEWEST -> "-id" SortOrder.NEWEST -> "-id"
else -> "-rating" else -> "-chapter_date"
} }
private fun parsePage(jo: JSONObject, referer: String) = MangaPage( 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 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) var gridSize by IntPreferenceDelegate(KEY_GRID_SIZE, defaultValue = 100)
val readerPageSwitch by StringSetPreferenceDelegate( val readerPageSwitch by StringSetPreferenceDelegate(
@@ -99,6 +101,9 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
var hiddenSources by StringSetPreferenceDelegate(KEY_SOURCES_HIDDEN) var hiddenSources by StringSetPreferenceDelegate(KEY_SOURCES_HIDDEN)
val isSourcesSelected: Boolean
get() = KEY_SOURCES_HIDDEN in prefs
fun getStorageDir(context: Context): File? { fun getStorageDir(context: Context): File? {
val value = prefs.getString(KEY_LOCAL_STORAGE, null)?.let { val value = prefs.getString(KEY_LOCAL_STORAGE, null)?.let {
File(it) File(it)
@@ -147,6 +152,7 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
const val KEY_APP_SECTION = "app_section" const val KEY_APP_SECTION = "app_section"
const val KEY_THEME = "theme" const val KEY_THEME = "theme"
const val KEY_THEME_AMOLED = "amoled_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_ORDER = "sources_order"
const val KEY_SOURCES_HIDDEN = "sources_hidden" const val KEY_SOURCES_HIDDEN = "sources_hidden"
const val KEY_TRAFFIC_WARNING = "traffic_warning" const val KEY_TRAFFIC_WARNING = "traffic_warning"

View File

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

View File

@@ -7,6 +7,7 @@ import android.os.Bundle
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.content.pm.ShortcutManagerCompat
@@ -14,7 +15,6 @@ import androidx.core.graphics.Insets
import androidx.core.net.toFile import androidx.core.net.toFile
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator import com.google.android.material.tabs.TabLayoutMediator
@@ -41,7 +41,7 @@ import org.koitharu.kotatsu.utils.ext.getDisplayMessage
class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), class DetailsActivity : BaseActivity<ActivityDetailsBinding>(),
TabLayoutMediator.TabConfigurationStrategy { TabLayoutMediator.TabConfigurationStrategy {
private val viewModel by viewModel<DetailsViewModel> { private val viewModel by viewModel<DetailsViewModel>(mode = LazyThreadSafetyMode.NONE) {
parametersOf(MangaIntent.from(intent)) 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) menuInflater.inflate(R.menu.opt_details, menu)
return super.onCreateOptionsMenu(menu) return super.onCreateOptionsMenu(menu)
} }
@@ -144,7 +144,7 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(),
} }
R.id.action_delete -> { R.id.action_delete -> {
viewModel.manga.value?.let { m -> viewModel.manga.value?.let { m ->
MaterialAlertDialogBuilder(this) AlertDialog.Builder(this)
.setTitle(R.string.delete_manga) .setTitle(R.string.delete_manga)
.setMessage(getString(R.string.text_delete_local_manga, m.title)) .setMessage(getString(R.string.text_delete_local_manga, m.title))
.setPositiveButton(R.string.delete) { _, _ -> .setPositiveButton(R.string.delete) { _, _ ->
@@ -159,7 +159,7 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(),
viewModel.manga.value?.let { viewModel.manga.value?.let {
val chaptersCount = it.chapters?.size ?: 0 val chaptersCount = it.chapters?.size ?: 0
if (chaptersCount > 5) { if (chaptersCount > 5) {
MaterialAlertDialogBuilder(this) AlertDialog.Builder(this)
.setTitle(R.string.save_manga) .setTitle(R.string.save_manga)
.setMessage( .setMessage(
getString( getString(

View File

@@ -35,7 +35,7 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
View.OnLongClickListener { View.OnLongClickListener {
private val viewModel by sharedViewModel<DetailsViewModel>() 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 private var tagsJob: Job? = null
override fun onInflateView( override fun onInflateView(

View File

@@ -13,6 +13,7 @@ import androidx.core.graphics.drawable.toBitmap
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.utils.PendingIntentCompat
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -63,7 +64,7 @@ class DownloadNotification(private val context: Context) {
context, context,
startId, startId,
intent, intent,
PendingIntent.FLAG_CANCEL_CURRENT PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
) )
) )
} }
@@ -146,7 +147,7 @@ class DownloadNotification(private val context: Context) {
context, context,
manga.hashCode(), manga.hashCode(),
DetailsActivity.newIntent(context, manga), 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)) wakeLock.acquire(TimeUnit.HOURS.toMillis(1))
notification.fillFrom(manga) notification.fillFrom(manga)
notification.setCancelId(startId) notification.setCancelId(startId)
withContext(Dispatchers.Main.immediate) { withContext(Dispatchers.Main) {
startForeground(DownloadNotification.NOTIFICATION_ID, notification()) startForeground(DownloadNotification.NOTIFICATION_ID, notification())
} }
val destination = settings.getStorageDir(this@DownloadService) val destination = settings.getStorageDir(this@DownloadService)
@@ -174,8 +174,8 @@ class DownloadService : BaseService() {
withContext(NonCancellable) { withContext(NonCancellable) {
jobs.remove(startId) jobs.remove(startId)
output?.cleanup() output?.cleanup()
destination.sub("page.tmp").delete() destination.sub(TEMP_PAGE_FILE).deleteAwait()
withContext(Dispatchers.Main.immediate) { withContext(Dispatchers.Main) {
stopForeground(true) stopForeground(true)
notification.dismiss() notification.dismiss()
stopSelf(startId) stopSelf(startId)
@@ -196,13 +196,25 @@ class DownloadService : BaseService() {
.cacheControl(CacheUtils.CONTROL_DISABLED) .cacheControl(CacheUtils.CONTROL_DISABLED)
.get() .get()
.build() .build()
return retryUntilSuccess(3) { val call = okHttp.newCall(request)
okHttp.newCall(request).await().use { response -> var attempts = MAX_DOWNLOAD_ATTEMPTS
val file = destination.sub("page.tmp") val file = destination.sub(TEMP_PAGE_FILE)
file.outputStream().use { out -> while (true) {
response.body!!.byteStream().copyTo(out) 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_CHAPTERS_IDS = "chapters_ids"
private const val EXTRA_CANCEL_ID = "cancel_id" 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) { fun start(context: Context, manga: Manga, chaptersIds: Collection<Long>? = null) {
confirmDataTransfer(context) { confirmDataTransfer(context) {
val intent = Intent(context, DownloadService::class.java) 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.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module import org.koin.dsl.module
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.categories.FavouritesCategoriesViewModel import org.koitharu.kotatsu.favourites.ui.categories.FavouritesCategoriesViewModel
import org.koitharu.kotatsu.favourites.ui.categories.select.MangaCategoriesViewModel import org.koitharu.kotatsu.favourites.ui.categories.select.MangaCategoriesViewModel
@@ -13,11 +12,11 @@ val favouritesModule
single { FavouritesRepository(get()) } single { FavouritesRepository(get()) }
viewModel { (categoryId: Long) -> viewModel { categoryId ->
FavouritesListViewModel(categoryId, get(), get()) FavouritesListViewModel(categoryId.get(), get(), get())
} }
viewModel { FavouritesCategoriesViewModel(get()) } viewModel { FavouritesCategoriesViewModel(get()) }
viewModel { (manga: Manga) -> viewModel { manga ->
MangaCategoriesViewModel(manga, get()) MangaCategoriesViewModel(manga.get(), get())
} }
} }

View File

@@ -1,12 +1,9 @@
package org.koitharu.kotatsu.favourites.domain package org.koitharu.kotatsu.favourites.domain
import androidx.collection.ArraySet
import androidx.room.withTransaction import androidx.room.withTransaction
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity import org.koitharu.kotatsu.core.db.entity.TagEntity
@@ -83,18 +80,15 @@ class FavouritesRepository(private val db: MangaDatabase) {
categoryId = 0 categoryId = 0
) )
val id = db.favouriteCategoriesDao.insert(entity) val id = db.favouriteCategoriesDao.insert(entity)
notifyCategoriesChanged()
return entity.toFavouriteCategory(id) return entity.toFavouriteCategory(id)
} }
suspend fun renameCategory(id: Long, title: String) { suspend fun renameCategory(id: Long, title: String) {
db.favouriteCategoriesDao.update(id, title) db.favouriteCategoriesDao.update(id, title)
notifyCategoriesChanged()
} }
suspend fun removeCategory(id: Long) { suspend fun removeCategory(id: Long) {
db.favouriteCategoriesDao.delete(id) db.favouriteCategoriesDao.delete(id)
notifyCategoriesChanged()
} }
suspend fun reorderCategories(orderedIds: List<Long>) { suspend fun reorderCategories(orderedIds: List<Long>) {
@@ -104,7 +98,6 @@ class FavouritesRepository(private val db: MangaDatabase) {
dao.update(id, i) dao.update(id, i)
} }
} }
notifyCategoriesChanged()
} }
suspend fun addToCategory(manga: Manga, categoryId: Long) { 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()) val entity = FavouriteEntity(manga.id, categoryId, System.currentTimeMillis())
db.favouritesDao.insert(entity) db.favouritesDao.insert(entity)
} }
notifyFavouritesChanged(manga.id)
} }
suspend fun removeFromCategory(manga: Manga, categoryId: Long) { suspend fun removeFromCategory(manga: Manga, categoryId: Long) {
db.favouritesDao.delete(categoryId, manga.id) db.favouritesDao.delete(categoryId, manga.id)
notifyFavouritesChanged(manga.id)
} }
suspend fun removeFromFavourites(manga: Manga) { suspend fun removeFromFavourites(manga: Manga) {
db.favouritesDao.delete(manga.id) 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() 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) { private val editDelegate by lazy(LazyThreadSafetyMode.NONE) {
CategoriesEditDelegate(requireContext(), this) CategoriesEditDelegate(requireContext(), this)
} }

View File

@@ -28,7 +28,9 @@ class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
OnListItemClickListener<FavouriteCategory>, OnListItemClickListener<FavouriteCategory>,
View.OnClickListener, CategoriesEditDelegate.CategoriesEditCallback { 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 adapter: CategoriesAdapter
private lateinit var reorderHelper: ItemTouchHelper private lateinit var reorderHelper: ItemTouchHelper

View File

@@ -2,7 +2,7 @@ package org.koitharu.kotatsu.favourites.ui.categories
import android.content.Context import android.content.Context
import android.text.InputType import android.text.InputType
import com.google.android.material.dialog.MaterialAlertDialogBuilder import androidx.appcompat.app.AlertDialog
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.dialog.TextInputDialog import org.koitharu.kotatsu.base.ui.dialog.TextInputDialog
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
@@ -13,7 +13,7 @@ class CategoriesEditDelegate(
) { ) {
fun deleteCategory(category: FavouriteCategory) { fun deleteCategory(category: FavouriteCategory) {
MaterialAlertDialogBuilder(context) AlertDialog.Builder(context)
.setMessage(context.getString(R.string.category_delete_confirm, category.title)) .setMessage(context.getString(R.string.category_delete_confirm, category.title))
.setTitle(R.string.remove_category) .setTitle(R.string.remove_category)
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)

View File

@@ -25,7 +25,7 @@ class FavouriteCategoriesDialog : BaseBottomSheet<DialogFavoriteCategoriesBindin
OnListItemClickListener<MangaCategoryItem>, CategoriesEditDelegate.CategoriesEditCallback, OnListItemClickListener<MangaCategoryItem>, CategoriesEditDelegate.CategoriesEditCallback,
View.OnClickListener { 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))) parametersOf(requireNotNull(arguments?.getParcelable<Manga>(MangaIntent.KEY_MANGA)))
} }

View File

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

View File

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

View File

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

@@ -5,7 +5,7 @@ import android.view.Menu
import android.view.MenuInflater import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import com.google.android.material.dialog.MaterialAlertDialogBuilder import androidx.appcompat.app.AlertDialog
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@@ -15,7 +15,7 @@ import org.koitharu.kotatsu.utils.ext.ellipsize
class HistoryListFragment : MangaListFragment() { class HistoryListFragment : MangaListFragment() {
override val viewModel by viewModel<HistoryListViewModel>() override val viewModel by viewModel<HistoryListViewModel>(mode = LazyThreadSafetyMode.NONE)
override val isSwipeRefreshEnabled = false override val isSwipeRefreshEnabled = false
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -42,7 +42,7 @@ class HistoryListFragment : MangaListFragment() {
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) { return when (item.itemId) {
R.id.action_clear_history -> { R.id.action_clear_history -> {
MaterialAlertDialogBuilder(context ?: return false) AlertDialog.Builder(context ?: return false)
.setTitle(R.string.clear_history) .setTitle(R.string.clear_history)
.setMessage(R.string.text_clear_history_prompt) .setMessage(R.string.text_clear_history_prompt)
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)

View File

@@ -18,7 +18,7 @@ import org.koitharu.kotatsu.databinding.DialogListModeBinding
class ListModeSelectDialog : AlertDialogFragment<DialogListModeBinding>(), View.OnClickListener, class ListModeSelectDialog : AlertDialogFragment<DialogListModeBinding>(), View.OnClickListener,
SeekBar.OnSeekBarChangeListener { 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 mode: ListMode = ListMode.GRID
private var pendingGridSize: Int = 100 private var pendingGridSize: Int = 100

View File

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

View File

@@ -175,10 +175,12 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
} }
fun getAvailableStorageDirs(context: Context): List<File> { fun getAvailableStorageDirs(context: Context): List<File> {
val result = ArrayList<File>(5) val result = ArrayList<File?>(5)
result += context.filesDir.sub(DIR_NAME) result += File(context.filesDir, DIR_NAME)
result += context.getExternalFilesDirs(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? { fun getFallbackStorageDir(context: Context): File? {

View File

@@ -9,7 +9,7 @@ import android.view.MenuItem
import android.view.View import android.view.View
import androidx.activity.result.ActivityResultCallback import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import com.google.android.material.dialog.MaterialAlertDialogBuilder import androidx.appcompat.app.AlertDialog
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
@@ -20,7 +20,7 @@ import org.koitharu.kotatsu.utils.ext.ellipsize
class LocalListFragment : MangaListFragment(), ActivityResultCallback<Uri> { class LocalListFragment : MangaListFragment(), ActivityResultCallback<Uri> {
override val viewModel by viewModel<LocalListViewModel>() override val viewModel by viewModel<LocalListViewModel>(mode = LazyThreadSafetyMode.NONE)
private val importCall = registerForActivityResult( private val importCall = registerForActivityResult(
ActivityResultContracts.OpenDocument(), ActivityResultContracts.OpenDocument(),
this this
@@ -77,7 +77,7 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback<Uri> {
override fun onPopupMenuItemSelected(item: MenuItem, data: Manga): Boolean { override fun onPopupMenuItemSelected(item: MenuItem, data: Manga): Boolean {
return when (item.itemId) { return when (item.itemId) {
R.id.action_delete -> { R.id.action_delete -> {
MaterialAlertDialogBuilder(context ?: return false) AlertDialog.Builder(context ?: return false)
.setTitle(R.string.delete_manga) .setTitle(R.string.delete_manga)
.setMessage(getString(R.string.text_delete_local_manga, data.title)) .setMessage(getString(R.string.text_delete_local_manga, data.title))
.setPositiveButton(R.string.delete) { _, _ -> .setPositiveButton(R.string.delete) { _, _ ->

View File

@@ -13,5 +13,5 @@ val mainModule
single { AppProtectHelper(get()) } single { AppProtectHelper(get()) }
single { ShortcutsRepository(androidContext(), get(), get(), get()) } single { ShortcutsRepository(androidContext(), get(), get(), get()) }
viewModel { MainViewModel(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.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.appcompat.app.ActionBarDrawerToggle import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.app.AlertDialog
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.* import androidx.core.view.*
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentTransaction
import androidx.fragment.app.commit
import androidx.swiperefreshlayout.widget.CircularProgressDrawable import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import com.google.android.material.navigation.NavigationView import com.google.android.material.navigation.NavigationView
import com.google.android.material.snackbar.Snackbar 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.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity 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.model.MangaSource
import org.koitharu.kotatsu.core.prefs.AppSection import org.koitharu.kotatsu.core.prefs.AppSection
import org.koitharu.kotatsu.databinding.ActivityMainBinding import org.koitharu.kotatsu.databinding.ActivityMainBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.favourites.ui.FavouritesContainerFragment import org.koitharu.kotatsu.favourites.ui.FavouritesContainerFragment
import org.koitharu.kotatsu.history.ui.HistoryListFragment import org.koitharu.kotatsu.history.ui.HistoryListFragment
import org.koitharu.kotatsu.local.ui.LocalListFragment 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.reader.ui.ReaderActivity
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment 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.AppUpdateChecker
import org.koitharu.kotatsu.settings.SettingsActivity 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.ui.FeedFragment
import org.koitharu.kotatsu.tracker.work.TrackWorker import org.koitharu.kotatsu.tracker.work.TrackWorker
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.resolveDp import org.koitharu.kotatsu.utils.ext.resolveDp
import java.io.Closeable
class MainActivity : BaseActivity<ActivityMainBinding>(), class MainActivity : BaseActivity<ActivityMainBinding>(),
NavigationView.OnNavigationItemSelectedListener, NavigationView.OnNavigationItemSelectedListener,
View.OnClickListener { View.OnClickListener, SearchSuggestionListener, MenuItem.OnActionExpandListener {
private val viewModel by viewModel<MainViewModel>() private val viewModel by viewModel<MainViewModel>(mode = LazyThreadSafetyMode.NONE)
private val protectHelper by inject<AppProtectHelper>() private val searchSuggestionViewModel by viewModel<SearchSuggestionViewModel>(
mode = LazyThreadSafetyMode.NONE
)
private lateinit var drawerToggle: ActionBarDrawerToggle private lateinit var drawerToggle: ActionBarDrawerToggle
private var closeable: Closeable? = null private var searchUi: SearchUI? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if (protectHelper.check(this)) {
finish()
return
}
setContentView(ActivityMainBinding.inflate(layoutInflater)) setContentView(ActivityMainBinding.inflate(layoutInflater))
drawerToggle = drawerToggle = ActionBarDrawerToggle(
ActionBarDrawerToggle(
this, this,
binding.drawer, binding.drawer,
binding.toolbar, binding.toolbar,
@@ -75,7 +80,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
setOnClickListener(this@MainActivity) setOnClickListener(this@MainActivity)
} }
supportFragmentManager.findFragmentById(R.id.container)?.let { supportFragmentManager.findFragmentByTag(TAG_PRIMARY)?.let {
binding.fab.isVisible = it is HistoryListFragment binding.fab.isVisible = it is HistoryListFragment
} ?: run { } ?: run {
openDefaultSection() openDefaultSection()
@@ -83,6 +88,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
if (savedInstanceState == null) { if (savedInstanceState == null) {
TrackWorker.setup(applicationContext) TrackWorker.setup(applicationContext)
AppUpdateChecker(this).launchIfNeeded() AppUpdateChecker(this).launchIfNeeded()
OnboardDialogFragment.showWelcome(get(), supportFragmentManager)
} }
viewModel.onOpenReader.observe(this, this::onOpenReader) viewModel.onOpenReader.observe(this, this::onOpenReader)
@@ -91,12 +97,6 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
viewModel.remoteSources.observe(this, this::updateSideMenu) viewModel.remoteSources.observe(this, this::updateSideMenu)
} }
override fun onDestroy() {
closeable?.close()
protectHelper.lock()
super.onDestroy()
}
override fun onPostCreate(savedInstanceState: Bundle?) { override fun onPostCreate(savedInstanceState: Bundle?) {
super.onPostCreate(savedInstanceState) super.onPostCreate(savedInstanceState)
drawerToggle.syncState() drawerToggle.syncState()
@@ -117,8 +117,10 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
override fun onCreateOptionsMenu(menu: Menu): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.opt_main, menu) menuInflater.inflate(R.menu.opt_main, menu)
menu.findItem(R.id.action_search)?.let { menuItem -> searchUi = menu.findItem(R.id.action_search)?.let { menuItem ->
closeable = SearchHelper.setupSearchView(menuItem) onMenuItemActionCollapse(menuItem)
menuItem.setOnActionExpandListener(this)
SearchUI.from(menuItem, this)
} }
return super.onCreateOptionsMenu(menu) return super.onCreateOptionsMenu(menu)
} }
@@ -139,28 +141,32 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
if (item.groupId == R.id.group_remote_sources) { if (item.groupId == R.id.group_remote_sources) {
val source = MangaSource.values().getOrNull(item.itemId) ?: return false val source = MangaSource.values().getOrNull(item.itemId) ?: return false
setPrimaryFragment(RemoteListFragment.newInstance(source)) setPrimaryFragment(RemoteListFragment.newInstance(source))
} else when (item.itemId) { searchSuggestionViewModel.onSourceChanged(source)
R.id.nav_history -> { } else {
viewModel.defaultSection = AppSection.HISTORY searchSuggestionViewModel.onSourceChanged(null)
setPrimaryFragment(HistoryListFragment.newInstance()) 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() binding.drawer.closeDrawers()
return true 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) { private fun onOpenReader(manga: Manga) {
val options = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { val options = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
ActivityOptions.makeClipRevealAnimation( ActivityOptions.makeClipRevealAnimation(
@@ -242,8 +304,14 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
private fun setPrimaryFragment(fragment: Fragment) { private fun setPrimaryFragment(fragment: Fragment) {
supportFragmentManager.beginTransaction() supportFragmentManager.beginTransaction()
.replace(R.id.container, fragment) .replace(R.id.container, fragment, TAG_PRIMARY)
.commit() .commit()
binding.fab.isVisible = fragment is HistoryListFragment 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 package org.koitharu.kotatsu.main.ui.protect
import android.app.Activity import android.app.Activity
import android.app.Application
import android.content.Intent import android.content.Intent
import android.os.Bundle
import org.koitharu.kotatsu.core.prefs.AppSettings 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() private var isUnlocked = settings.appPassword.isNullOrEmpty()
fun unlock(activity: Activity) { override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
isUnlocked = true if (activity !is ProtectActivity && !isUnlocked) {
with(activity) { val sourceIntent = Intent(activity, activity.javaClass)
startActivity(Intent(this, MainActivity::class.java) activity.intent?.let {
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)) 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() 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.Editable
import android.text.TextWatcher import android.text.TextWatcher
import android.view.KeyEvent import android.view.KeyEvent
import android.view.Menu import android.view.View
import android.view.MenuItem
import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo
import android.widget.TextView import android.widget.TextView
import androidx.core.graphics.Insets 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.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.base.ui.BaseActivity
@@ -20,50 +17,49 @@ import org.koitharu.kotatsu.databinding.ActivityProtectBinding
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
class ProtectActivity : BaseActivity<ActivityProtectBinding>(), TextView.OnEditorActionListener, class ProtectActivity : BaseActivity<ActivityProtectBinding>(), TextView.OnEditorActionListener,
TextWatcher { TextWatcher, View.OnClickListener {
private val viewModel by viewModel<ProtectViewModel>() private val viewModel by viewModel<ProtectViewModel>(mode = LazyThreadSafetyMode.NONE)
private val appProtectHelper by inject<AppProtectHelper>()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(ActivityProtectBinding.inflate(layoutInflater)) setContentView(ActivityProtectBinding.inflate(layoutInflater))
binding.editPassword.setOnEditorActionListener(this) binding.editPassword.setOnEditorActionListener(this)
binding.editPassword.addTextChangedListener(this) binding.editPassword.addTextChangedListener(this)
supportActionBar?.run { binding.buttonNext.setOnClickListener(this)
setDisplayHomeAsUpEnabled(true) binding.buttonCancel.setOnClickListener(this)
setHomeAsUpIndicator(R.drawable.ic_cross)
}
viewModel.onError.observe(this, this::onError) viewModel.onError.observe(this, this::onError)
viewModel.isLoading.observe(this, this::onLoadingStateChanged) viewModel.isLoading.observe(this, this::onLoadingStateChanged)
viewModel.onUnlockSuccess.observe(this, this::onUnlockSuccess) viewModel.onUnlockSuccess.observe(this) {
} val intent = intent.getParcelableExtra<Intent>(EXTRA_INTENT)
startActivity(intent)
override fun onCreateOptionsMenu(menu: Menu?): Boolean { finishAfterTransition()
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
} }
else -> super.onOptionsItemSelected(item)
binding.editPassword.requestFocus()
} }
override fun onWindowInsetsChanged(insets: Insets) { override fun onWindowInsetsChanged(insets: Insets) {
binding.toolbar.updatePadding( val basePadding = resources.getDimensionPixelOffset(R.dimen.screen_padding)
left = insets.left, binding.root.setPadding(
right = insets.right, basePadding + insets.left,
top = insets.top 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 { override fun onEditorAction(v: TextView?, actionId: Int, event: KeyEvent?): Boolean {
return if (actionId == EditorInfo.IME_ACTION_DONE) { return if (actionId == EditorInfo.IME_ACTION_DONE && binding.buttonNext.isEnabled) {
viewModel.tryUnlock(binding.editPassword.text.toString().orEmpty()) binding.buttonNext.performClick()
true true
} else { } else {
false false
@@ -76,10 +72,7 @@ class ProtectActivity : BaseActivity<ActivityProtectBinding>(), TextView.OnEdito
override fun afterTextChanged(s: Editable?) { override fun afterTextChanged(s: Editable?) {
binding.layoutPassword.error = null binding.layoutPassword.error = null
} binding.buttonNext.isEnabled = !s.isNullOrEmpty()
private fun onUnlockSuccess(unit: Unit) {
appProtectHelper.unlock(this)
} }
private fun onError(e: Throwable) { private fun onError(e: Throwable) {
@@ -92,6 +85,11 @@ class ProtectActivity : BaseActivity<ActivityProtectBinding>(), TextView.OnEdito
companion object { 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 package org.koitharu.kotatsu.main.ui.protect
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.exceptions.WrongPasswordException import org.koitharu.kotatsu.core.exceptions.WrongPasswordException
@@ -8,16 +9,23 @@ import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.md5 import org.koitharu.kotatsu.utils.ext.md5
class ProtectViewModel( class ProtectViewModel(
private val settings: AppSettings private val settings: AppSettings,
private val protectHelper: AppProtectHelper,
) : BaseViewModel() { ) : BaseViewModel() {
private var job: Job? = null
val onUnlockSuccess = SingleLiveEvent<Unit>() val onUnlockSuccess = SingleLiveEvent<Unit>()
fun tryUnlock(password: String) { fun tryUnlock(password: String) {
launchLoadingJob { if (job?.isActive == true) {
return
}
job = launchLoadingJob {
val passwordHash = password.md5() val passwordHash = password.md5()
val appPasswordHash = settings.appPassword val appPasswordHash = settings.appPassword
if (passwordHash == appPasswordHash) { if (passwordHash == appPasswordHash) {
protectHelper.unlock()
onUnlockSuccess.call(Unit) onUnlockSuccess.call(Unit)
} else { } else {
delay(PASSWORD_COMPARE_DELAY) 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.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module import org.koin.dsl.module
import org.koitharu.kotatsu.base.domain.MangaDataRepository 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.local.data.PagesCache
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.ReaderViewModel import org.koitharu.kotatsu.reader.ui.ReaderViewModel
val readerModule val readerModule
@@ -14,7 +12,7 @@ val readerModule
single { MangaDataRepository(get()) } single { MangaDataRepository(get()) }
single { PagesCache(get()) } single { PagesCache(get()) }
viewModel { (intent: MangaIntent, state: ReaderState?) -> viewModel { params ->
ReaderViewModel(intent, state, get(), get(), get(), get()) ReaderViewModel(params[0], params[1], get(), get(), get(), get())
} }
} }

View File

@@ -10,12 +10,12 @@ import android.view.*
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.ActivityResultCallback import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.* import androidx.core.view.*
import androidx.fragment.app.commit import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
@@ -52,7 +52,7 @@ class ReaderActivity : BaseFullscreenActivity<ActivityReaderBinding>(),
GridTouchHelper.OnGridTouchListener, OnPageSelectListener, ReaderConfigDialog.Callback, GridTouchHelper.OnGridTouchListener, OnPageSelectListener, ReaderConfigDialog.Callback,
ActivityResultCallback<Boolean>, ReaderControlDelegate.OnInteractionListener { 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)) parametersOf(MangaIntent.from(intent), intent?.getParcelableExtra<ReaderState>(EXTRA_STATE))
} }
@@ -124,7 +124,7 @@ class ReaderActivity : BaseFullscreenActivity<ActivityReaderBinding>(),
} }
} }
override fun onCreateOptionsMenu(menu: Menu?): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.opt_reader_top, menu) menuInflater.inflate(R.menu.opt_reader_top, menu)
return super.onCreateOptionsMenu(menu) return super.onCreateOptionsMenu(menu)
} }
@@ -207,7 +207,7 @@ class ReaderActivity : BaseFullscreenActivity<ActivityReaderBinding>(),
} }
private fun onError(e: Throwable) { private fun onError(e: Throwable) {
val dialog = MaterialAlertDialogBuilder(this) val dialog = AlertDialog.Builder(this)
.setTitle(R.string.error_occurred) .setTitle(R.string.error_occurred)
.setMessage(e.getDisplayMessage(resources)) .setMessage(e.getDisplayMessage(resources))
.setPositiveButton(R.string.close, null) .setPositiveButton(R.string.close, null)

View File

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

View File

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

View File

@@ -1,21 +1,16 @@
package org.koitharu.kotatsu.remotelist.ui 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.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaFilter import org.koitharu.kotatsu.core.model.MangaFilter
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.list.ui.MangaListFragment 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.parcelableArgument
import org.koitharu.kotatsu.utils.ext.withArgs import org.koitharu.kotatsu.utils.ext.withArgs
class RemoteListFragment : MangaListFragment() { class RemoteListFragment : MangaListFragment() {
override val viewModel by viewModel<RemoteListViewModel> { override val viewModel by viewModel<RemoteListViewModel>(mode = LazyThreadSafetyMode.NONE) {
parametersOf(source) parametersOf(source)
} }
@@ -25,7 +20,7 @@ class RemoteListFragment : MangaListFragment() {
viewModel.loadNextPage() viewModel.loadNextPage()
} }
override fun getTitle(): CharSequence? { override fun getTitle(): CharSequence {
return source.title return source.title
} }
@@ -34,19 +29,6 @@ class RemoteListFragment : MangaListFragment() {
super.onFilterChanged(filter) 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 { companion object {
private const val ARG_SOURCE = "provider" private const val ARG_SOURCE = "provider"

View File

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

View File

@@ -1,40 +1,126 @@
package org.koitharu.kotatsu.search.domain package org.koitharu.kotatsu.search.domain
import kotlinx.coroutines.flow.Flow import android.annotation.SuppressLint
import kotlinx.coroutines.flow.flow 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.base.domain.MangaProviderFactory
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.SortOrder import org.koitharu.kotatsu.core.model.SortOrder
import org.koitharu.kotatsu.core.prefs.AppSettings 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 { fun globalSearch(query: String, concurrency: Int = DEFAULT_CONCURRENCY): Flow<Manga> =
val sources = MangaProviderFactory.getSources(settings, includeHidden = false) MangaProviderFactory.getSources(settings, includeHidden = false).asFlow()
val lists = EnumMap<MangaSource, List<Manga>>(MangaSource::class.java) .flatMapMerge(concurrency) { source ->
var i = 0 runCatching {
while (true) { source.repository.getList(0, query, SortOrder.POPULARITY)
var isEmitted = false }.getOrElse {
for (source in sources) { emptyList()
val list = lists.getOrPut(source) { }.asFlow()
try { }.filter {
source.repository.getList(0, query, SortOrder.POPULARITY) match(it, query)
} catch (e: Throwable) { }
e.printStackTrace()
emptyList<Manga>() 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.ContentResolver
import android.content.Context import android.content.Context
import android.content.SearchRecentSuggestionsProvider import android.content.SearchRecentSuggestionsProvider
import android.database.Cursor
import android.net.Uri import android.net.Uri
import android.provider.SearchRecentSuggestions 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.BuildConfig
import org.koitharu.kotatsu.R
class MangaSuggestionsProvider : SearchRecentSuggestionsProvider() { class MangaSuggestionsProvider : SearchRecentSuggestionsProvider() {
init { init {
setupSuggestions( setupSuggestions(AUTHORITY, MODE)
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()
}
} }
companion object { companion object {
@@ -54,65 +19,16 @@ class MangaSuggestionsProvider : SearchRecentSuggestionsProvider() {
private const val AUTHORITY = "${BuildConfig.APPLICATION_ID}.MangaSuggestionsProvider" private const val AUTHORITY = "${BuildConfig.APPLICATION_ID}.MangaSuggestionsProvider"
private const val MODE = DATABASE_MODE_QUERIES 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) .scheme(ContentResolver.SCHEME_CONTENT)
.authority(AUTHORITY) .authority(AUTHORITY)
.appendPath(SearchManager.SUGGEST_URI_PATH_QUERY) .appendPath(SearchManager.SUGGEST_URI_PATH_QUERY)
.build() .build()
private val projection = arrayOf("_id", SearchManager.SUGGEST_COLUMN_QUERY) val URI: Uri = Uri.parse("content://$AUTHORITY/suggestions")
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
)
}
}
}
} }
} }

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,51 @@
package org.koitharu.kotatsu.search.ui.suggestion.adapter
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DiffUtil
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem
import kotlin.jvm.internal.Intrinsics
class SearchSuggestionAdapter(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
listener: SearchSuggestionListener,
) : AsyncListDifferDelegationAdapter<SearchSuggestionItem>(DiffCallback()) {
init {
delegatesManager.addDelegate(ITEM_TYPE_MANGA, searchSuggestionMangaAD(coil, lifecycleOwner, listener))
.addDelegate(ITEM_TYPE_QUERY, searchSuggestionQueryAD(listener))
.addDelegate(ITEM_TYPE_HEADER, searchSuggestionHeaderAD(listener))
}
private class DiffCallback : DiffUtil.ItemCallback<SearchSuggestionItem>() {
override fun areItemsTheSame(
oldItem: SearchSuggestionItem,
newItem: SearchSuggestionItem,
): Boolean = when {
oldItem is SearchSuggestionItem.MangaItem && newItem is SearchSuggestionItem.MangaItem -> {
oldItem.manga.id == newItem.manga.id
}
oldItem is SearchSuggestionItem.RecentQuery && newItem is SearchSuggestionItem.RecentQuery -> {
oldItem.query == newItem.query
}
oldItem is SearchSuggestionItem.Header && newItem is SearchSuggestionItem.Header -> true
else -> false
}
override fun areContentsTheSame(
oldItem: SearchSuggestionItem,
newItem: SearchSuggestionItem,
): Boolean = Intrinsics.areEqual(oldItem, newItem)
}
companion object {
const val ITEM_TYPE_MANGA = 0
const val ITEM_TYPE_QUERY = 1
const val ITEM_TYPE_HEADER = 2
}
}

View File

@@ -0,0 +1,29 @@
package org.koitharu.kotatsu.search.ui.suggestion.adapter
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemSearchSuggestionHeaderBinding
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem
fun searchSuggestionHeaderAD(
listener: SearchSuggestionListener,
) = adapterDelegateViewBinding<SearchSuggestionItem.Header, SearchSuggestionItem, ItemSearchSuggestionHeaderBinding>(
{ inflater, parent -> ItemSearchSuggestionHeaderBinding.inflate(inflater, parent, false) }
) {
binding.switchLocal.setOnCheckedChangeListener { _, isChecked ->
item.isChecked.value = isChecked
}
binding.buttonClear.setOnClickListener {
listener.onClearSearchHistory()
}
bind {
binding.switchLocal.text = getString(
R.string.search_only_on_s,
item.source.title,
)
binding.switchLocal.isChecked = item.isChecked.value
}
}

View File

@@ -0,0 +1,46 @@
package org.koitharu.kotatsu.search.ui.suggestion.adapter
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import coil.request.Disposable
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemSearchSuggestionMangaBinding
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.textAndVisible
fun searchSuggestionMangaAD(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
listener: SearchSuggestionListener,
) = adapterDelegateViewBinding<SearchSuggestionItem.MangaItem, SearchSuggestionItem, ItemSearchSuggestionMangaBinding>(
{ inflater, parent -> ItemSearchSuggestionMangaBinding.inflate(inflater, parent, false) }
) {
var imageRequest: Disposable? = null
itemView.setOnClickListener {
listener.onMangaClick(item.manga)
}
bind {
imageRequest?.dispose()
imageRequest = binding.imageViewCover.newImageRequest(item.manga.coverUrl)
.placeholder(R.drawable.ic_placeholder)
.fallback(R.drawable.ic_placeholder)
.error(R.drawable.ic_placeholder)
.allowRgb565(true)
.lifecycle(lifecycleOwner)
.enqueueWith(coil)
binding.textViewTitle.text = item.manga.title
binding.textViewSubtitle.textAndVisible = item.manga.altTitle
}
onViewRecycled {
imageRequest?.dispose()
binding.imageViewCover.setImageDrawable(null)
}
}

View File

@@ -0,0 +1,26 @@
package org.koitharu.kotatsu.search.ui.suggestion.adapter
import android.view.View
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemSearchSuggestionQueryBinding
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem
fun searchSuggestionQueryAD(
listener: SearchSuggestionListener,
) = adapterDelegateViewBinding<SearchSuggestionItem.RecentQuery, SearchSuggestionItem, ItemSearchSuggestionQueryBinding>(
{ inflater, parent -> ItemSearchSuggestionQueryBinding.inflate(inflater, parent, false) }
) {
val viewClickListener = View.OnClickListener { v ->
listener.onQueryClick(item.query, v.id != R.id.button_complete)
}
binding.root.setOnClickListener(viewClickListener)
binding.buttonComplete.setOnClickListener(viewClickListener)
bind {
binding.textViewTitle.text = item.query
}
}

View File

@@ -0,0 +1,21 @@
package org.koitharu.kotatsu.search.ui.suggestion.model
import kotlinx.coroutines.flow.MutableStateFlow
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaSource
sealed class SearchSuggestionItem {
data class MangaItem(
val manga: Manga,
) : SearchSuggestionItem()
data class RecentQuery(
val query: String,
) : SearchSuggestionItem()
data class Header(
val source: MangaSource,
val isChecked: MutableStateFlow<Boolean>,
) : SearchSuggestionItem()
}

View File

@@ -7,8 +7,8 @@ import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.annotation.MainThread import androidx.annotation.MainThread
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -77,7 +77,7 @@ class AppUpdateChecker(private val activity: ComponentActivity) {
@MainThread @MainThread
private fun showUpdateDialog(version: AppVersion) { private fun showUpdateDialog(version: AppVersion) {
MaterialAlertDialogBuilder(activity) AlertDialog.Builder(activity)
.setTitle(R.string.app_update_available) .setTitle(R.string.app_update_available)
.setMessage(buildString { .setMessage(buildString {
append(activity.getString(R.string.new_version_s, version.name)) append(activity.getString(R.string.new_version_s, version.name))

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.settings
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import androidx.appcompat.app.AlertDialog
import androidx.preference.Preference import androidx.preference.Preference
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -14,7 +15,7 @@ import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.network.AndroidCookieJar import org.koitharu.kotatsu.core.network.AndroidCookieJar
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.local.data.Cache import org.koitharu.kotatsu.local.data.Cache
import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider import org.koitharu.kotatsu.search.domain.MangaSearchRepository
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.CacheUtils import org.koitharu.kotatsu.utils.CacheUtils
import org.koitharu.kotatsu.utils.FileSizeUtils import org.koitharu.kotatsu.utils.FileSizeUtils
@@ -23,7 +24,8 @@ import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cache) { class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cache) {
private val trackerRepo by inject<TrackingRepository>() private val trackerRepo by inject<TrackingRepository>(mode = LazyThreadSafetyMode.NONE)
private val searchRepository by inject<MangaSearchRepository>(mode = LazyThreadSafetyMode.NONE)
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_history) addPreferencesFromResource(R.xml.pref_history)
@@ -49,7 +51,7 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
} }
findPreference<Preference>(AppSettings.KEY_SEARCH_HISTORY_CLEAR)?.let { pref -> findPreference<Preference>(AppSettings.KEY_SEARCH_HISTORY_CLEAR)?.let { pref ->
viewLifecycleScope.launchWhenResumed { viewLifecycleScope.launchWhenResumed {
val items = MangaSuggestionsProvider.getItemsCount(pref.context) val items = searchRepository.getSearchHistoryCount()
pref.summary = pref.summary =
pref.context.resources.getQuantityString(R.plurals.items, items, items) pref.context.resources.getQuantityString(R.plurals.items, items, items)
} }
@@ -86,16 +88,7 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
true true
} }
AppSettings.KEY_SEARCH_HISTORY_CLEAR -> { AppSettings.KEY_SEARCH_HISTORY_CLEAR -> {
viewLifecycleScope.launch { clearSearchHistory(preference)
MangaSuggestionsProvider.clearHistory(preference.context)
preference.summary = preference.context.resources
.getQuantityString(R.plurals.items, 0, 0)
Snackbar.make(
view ?: return@launch,
R.string.search_history_cleared,
Snackbar.LENGTH_SHORT
).show()
}
true true
} }
AppSettings.KEY_UPDATES_FEED_CLEAR -> { AppSettings.KEY_UPDATES_FEED_CLEAR -> {
@@ -132,4 +125,23 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
} }
} }
} }
private fun clearSearchHistory(preference: Preference) {
AlertDialog.Builder(context ?: return)
.setTitle(R.string.clear_search_history)
.setMessage(R.string.text_clear_search_history_prompt)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.clear) { _, _ ->
viewLifecycleScope.launch {
searchRepository.clearSearchHistory()
preference.summary = preference.context.resources
.getQuantityString(R.plurals.items, 0, 0)
Snackbar.make(
view ?: return@launch,
R.string.search_history_cleared,
Snackbar.LENGTH_SHORT
).show()
}
}.show()
}
} }

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.settings package org.koitharu.kotatsu.settings
import android.content.DialogInterface import android.content.DialogInterface
import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.Bundle import android.os.Bundle
import android.text.InputType import android.text.InputType
@@ -17,6 +18,7 @@ import org.koitharu.kotatsu.base.ui.dialog.TextInputDialog
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.settings.protect.ProtectSetupActivity
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.*
import java.io.File import java.io.File
@@ -71,12 +73,19 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
AppSettings.KEY_THEME_AMOLED -> { AppSettings.KEY_THEME_AMOLED -> {
findPreference<Preference>(key)?.setSummary(R.string.restart_required) findPreference<Preference>(key)?.setSummary(R.string.restart_required)
} }
AppSettings.KEY_HIDE_TOOLBAR -> {
findPreference<SwitchPreference>(key)?.setSummary(R.string.restart_required)
}
AppSettings.KEY_LOCAL_STORAGE -> { AppSettings.KEY_LOCAL_STORAGE -> {
findPreference<Preference>(key)?.run { findPreference<Preference>(key)?.run {
summary = settings.getStorageDir(context)?.getStorageName(context) summary = settings.getStorageDir(context)?.getStorageName(context)
?: getString(R.string.not_available) ?: getString(R.string.not_available)
} }
} }
AppSettings.KEY_APP_PASSWORD -> {
findPreference<SwitchPreference>(AppSettings.KEY_PROTECT_APP)
?.isChecked = !settings.appPassword.isNullOrEmpty()
}
} }
} }
@@ -102,8 +111,10 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
true true
} }
AppSettings.KEY_PROTECT_APP -> { AppSettings.KEY_PROTECT_APP -> {
if ((preference as? SwitchPreference ?: return false).isChecked) { val pref = (preference as? SwitchPreference ?: return false)
enableAppProtection(preference) if (pref.isChecked) {
pref.isChecked = false
startActivity(Intent(preference.context, ProtectSetupActivity::class.java))
} else { } else {
settings.appPassword = null settings.appPassword = null
} }

View File

@@ -9,6 +9,8 @@ import org.koitharu.kotatsu.core.backup.RestoreRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.settings.backup.BackupViewModel import org.koitharu.kotatsu.settings.backup.BackupViewModel
import org.koitharu.kotatsu.settings.backup.RestoreViewModel import org.koitharu.kotatsu.settings.backup.RestoreViewModel
import org.koitharu.kotatsu.settings.onboard.OnboardViewModel
import org.koitharu.kotatsu.settings.protect.ProtectSetupViewModel
val settingsModule val settingsModule
get() = module { get() = module {
@@ -18,5 +20,9 @@ val settingsModule
single { AppSettings(androidContext()) } single { AppSettings(androidContext()) }
viewModel { BackupViewModel(get(), androidContext()) } viewModel { BackupViewModel(get(), androidContext()) }
viewModel { (uri: Uri?) -> RestoreViewModel(uri, get(), androidContext()) } viewModel { params ->
RestoreViewModel(params.getOrNull(Uri::class), get(), androidContext())
}
viewModel { ProtectSetupViewModel(get()) }
viewModel { OnboardViewModel(get()) }
} }

View File

@@ -6,7 +6,6 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.AlertDialogFragment import org.koitharu.kotatsu.base.ui.AlertDialogFragment
@@ -18,7 +17,7 @@ import java.io.File
class BackupDialogFragment : AlertDialogFragment<DialogProgressBinding>() { class BackupDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
private val viewModel by viewModel<BackupViewModel>() private val viewModel by viewModel<BackupViewModel>(mode = LazyThreadSafetyMode.NONE)
override fun onInflateView( override fun onInflateView(
inflater: LayoutInflater, inflater: LayoutInflater,
@@ -41,7 +40,7 @@ class BackupDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
} }
private fun onError(e: Throwable) { private fun onError(e: Throwable) {
MaterialAlertDialogBuilder(context ?: return) AlertDialog.Builder(context ?: return)
.setNegativeButton(R.string.close, null) .setNegativeButton(R.string.close, null)
.setTitle(R.string.error) .setTitle(R.string.error)
.setMessage(e.getDisplayMessage(resources)) .setMessage(e.getDisplayMessage(resources))

View File

@@ -7,7 +7,6 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@@ -26,7 +25,7 @@ class RestoreDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
container: ViewGroup? container: ViewGroup?
) = DialogProgressBinding.inflate(inflater, container, false) ) = DialogProgressBinding.inflate(inflater, container, false)
private val viewModel by viewModel<RestoreViewModel> { private val viewModel by viewModel<RestoreViewModel>(mode = LazyThreadSafetyMode.NONE) {
parametersOf(arguments?.getString(ARG_FILE)?.toUriOrNull()) parametersOf(arguments?.getString(ARG_FILE)?.toUriOrNull())
} }
@@ -45,7 +44,7 @@ class RestoreDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
} }
private fun onError(e: Throwable) { private fun onError(e: Throwable) {
MaterialAlertDialogBuilder(context ?: return) AlertDialog.Builder(context ?: return)
.setNegativeButton(R.string.close, null) .setNegativeButton(R.string.close, null)
.setTitle(R.string.error) .setTitle(R.string.error)
.setMessage(e.getDisplayMessage(resources)) .setMessage(e.getDisplayMessage(resources))
@@ -65,7 +64,7 @@ class RestoreDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
} }
private fun onRestoreDone(result: CompositeResult) { private fun onRestoreDone(result: CompositeResult) {
val builder = MaterialAlertDialogBuilder(context ?: return) val builder = AlertDialog.Builder(context ?: return)
when { when {
result.isAllSuccess -> builder.setTitle(R.string.data_restored) result.isAllSuccess -> builder.setTitle(R.string.data_restored)
.setMessage(R.string.data_restored_success) .setMessage(R.string.data_restored_success)

View File

@@ -0,0 +1,86 @@
package org.koitharu.kotatsu.settings.onboard
import android.content.DialogInterface
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.FragmentManager
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.AlertDialogFragment
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.databinding.DialogOnboardBinding
import org.koitharu.kotatsu.settings.onboard.adapter.SourceLocalesAdapter
import org.koitharu.kotatsu.settings.onboard.model.SourceLocale
import org.koitharu.kotatsu.utils.ext.observeNotNull
import org.koitharu.kotatsu.utils.ext.withArgs
class OnboardDialogFragment : AlertDialogFragment<DialogOnboardBinding>(),
OnListItemClickListener<SourceLocale>, DialogInterface.OnClickListener {
private val viewModel by viewModel<OnboardViewModel>(mode = LazyThreadSafetyMode.NONE)
private var isWelcome: Boolean = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
arguments?.run {
isWelcome = getBoolean(ARG_WELCOME, false)
}
}
override fun onInflateView(
inflater: LayoutInflater,
container: ViewGroup?,
) = DialogOnboardBinding.inflate(inflater, container, false)
override fun onBuildDialog(builder: AlertDialog.Builder) {
builder
.setPositiveButton(R.string.done, this)
.setCancelable(true)
if (isWelcome) {
builder.setTitle(R.string.welcome)
} else {
builder
.setTitle(R.string.remote_sources)
.setNegativeButton(android.R.string.cancel, this)
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val adapter = SourceLocalesAdapter(this)
binding.recyclerView.adapter = adapter
viewModel.list.observeNotNull(viewLifecycleOwner) {
adapter.items = it
}
}
override fun onItemClick(item: SourceLocale, view: View) {
viewModel.setItemChecked(item.key, !item.isChecked)
}
override fun onClick(dialog: DialogInterface?, which: Int) {
when (which) {
DialogInterface.BUTTON_POSITIVE -> viewModel.apply()
}
}
companion object {
private const val TAG = "OnboardDialog"
private const val ARG_WELCOME = "welcome"
fun show(fm: FragmentManager) = OnboardDialogFragment().show(fm, TAG)
fun showWelcome(settings: AppSettings, fm: FragmentManager) {
if (!settings.isSourcesSelected) {
OnboardDialogFragment().withArgs(1) {
putBoolean(ARG_WELCOME, true)
}.show(fm, TAG)
}
}
}
}

View File

@@ -0,0 +1,94 @@
package org.koitharu.kotatsu.settings.onboard
import androidx.collection.ArraySet
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.MutableLiveData
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.settings.onboard.model.SourceLocale
import org.koitharu.kotatsu.utils.ext.map
import org.koitharu.kotatsu.utils.ext.mapToSet
import java.util.*
class OnboardViewModel(
private val settings: AppSettings,
) : BaseViewModel() {
private val allSources = MangaSource.values().filterNot { x -> x == MangaSource.LOCAL }
private val locales = allSources.mapTo(ArraySet()) {
it.locale
}
private val selectedLocales = locales.toMutableSet()
val list = MutableLiveData<List<SourceLocale>?>()
init {
if (settings.isSourcesSelected) {
selectedLocales.removeAll(settings.hiddenSources.map { x -> MangaSource.valueOf(x).locale })
} else {
val deviceLocales = LocaleListCompat.getDefault().map { x ->
x.language
}
selectedLocales.retainAll(deviceLocales)
if (selectedLocales.isEmpty()) {
selectedLocales += "en"
}
}
rebuildList()
}
fun setItemChecked(key: String?, isChecked: Boolean) {
val isModified = if (isChecked) {
selectedLocales.add(key)
} else {
selectedLocales.remove(key)
}
if (isModified) {
rebuildList()
}
}
fun apply() {
settings.hiddenSources = allSources.filterNot { x ->
x.locale in selectedLocales
}.mapToSet { x -> x.name }
}
private fun rebuildList() {
list.value = locales.map { key ->
val locale = if (key != null) {
Locale(key)
} else null
SourceLocale(
key = key,
title = locale?.getDisplayLanguage(locale)?.capitalize(locale),
isChecked = key in selectedLocales
)
}.sortedWith(SourceLocaleComparator())
}
private class SourceLocaleComparator : Comparator<SourceLocale?> {
private val deviceLocales = LocaleListCompat.getAdjustedDefault()
.map { it.language }
override fun compare(a: SourceLocale?, b: SourceLocale?): Int {
return when {
a === b -> 0
a?.key == null -> 1
b?.key == null -> -1
else -> {
val index = deviceLocales.indexOf(a.key)
if (index == -1) {
compareValues(a.title, b.title)
} else {
-2 - index
}
}
}
}
}
}

View File

@@ -0,0 +1,23 @@
package org.koitharu.kotatsu.settings.onboard.adapter
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.databinding.ItemSourceLocaleBinding
import org.koitharu.kotatsu.settings.onboard.model.SourceLocale
fun sourceLocaleAD(
clickListener: OnListItemClickListener<SourceLocale>
) = adapterDelegateViewBinding<SourceLocale, SourceLocale, ItemSourceLocaleBinding>(
{ inflater, parent -> ItemSourceLocaleBinding.inflate(inflater, parent, false) }
) {
binding.root.setOnClickListener {
clickListener.onItemClick(item, it)
}
bind {
binding.root.text = item.title ?: getString(R.string.other)
binding.root.isChecked = item.isChecked
}
}

View File

@@ -0,0 +1,28 @@
package org.koitharu.kotatsu.settings.onboard.adapter
import androidx.recyclerview.widget.DiffUtil
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.settings.onboard.model.SourceLocale
class SourceLocalesAdapter(
clickListener: OnListItemClickListener<SourceLocale>,
) : AsyncListDifferDelegationAdapter<SourceLocale>(DiffCallback()) {
init {
delegatesManager.addDelegate(sourceLocaleAD(clickListener))
}
private class DiffCallback : DiffUtil.ItemCallback<SourceLocale>() {
override fun areItemsTheSame(
oldItem: SourceLocale,
newItem: SourceLocale,
): Boolean = oldItem.key == newItem.key
override fun areContentsTheSame(
oldItem: SourceLocale,
newItem: SourceLocale,
): Boolean = oldItem == newItem
}
}

View File

@@ -0,0 +1,20 @@
package org.koitharu.kotatsu.settings.onboard.model
import java.util.*
data class SourceLocale(
val key: String?,
val title: String?,
val isChecked: Boolean,
) : Comparable<SourceLocale> {
override fun compareTo(other: SourceLocale): Int {
return when {
this === other -> 0
key == Locale.getDefault().language -> -2
key == null -> 1
other.key == null -> -1
else -> compareValues(title, other.title)
}
}
}

View File

@@ -0,0 +1,97 @@
package org.koitharu.kotatsu.settings.protect
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.view.KeyEvent
import android.view.View
import android.view.inputmethod.EditorInfo
import android.widget.TextView
import androidx.core.graphics.Insets
import androidx.core.view.isGone
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.databinding.ActivitySetupProtectBinding
class ProtectSetupActivity : BaseActivity<ActivitySetupProtectBinding>(), TextWatcher,
View.OnClickListener, TextView.OnEditorActionListener {
private val viewModel by viewModel<ProtectSetupViewModel>(mode = LazyThreadSafetyMode.NONE)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivitySetupProtectBinding.inflate(layoutInflater))
binding.editPassword.addTextChangedListener(this)
binding.editPassword.setOnEditorActionListener(this)
binding.buttonNext.setOnClickListener(this)
binding.buttonCancel.setOnClickListener(this)
viewModel.isSecondStep.observe(this, this::onStepChanged)
viewModel.onPasswordSet.observe(this) {
finishAfterTransition()
}
viewModel.onPasswordMismatch.observe(this) {
binding.editPassword.error = getString(R.string.passwords_mismatch)
}
viewModel.onClearText.observe(this) {
binding.editPassword.text?.clear()
}
}
override fun onWindowInsetsChanged(insets: Insets) {
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_cancel -> finish()
R.id.button_next -> viewModel.onNextClick(
password = binding.editPassword.text?.toString() ?: return
)
}
}
override fun onEditorAction(v: TextView?, actionId: Int, event: KeyEvent?): Boolean {
return if (actionId == EditorInfo.IME_ACTION_DONE && binding.buttonNext.isEnabled) {
binding.buttonNext.performClick()
true
} else {
false
}
}
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
override fun afterTextChanged(s: Editable?) {
binding.editPassword.error = null
val isEnoughLength = (s?.length ?: 0) >= MIN_PASSWORD_LENGTH
binding.buttonNext.isEnabled = isEnoughLength
binding.layoutPassword.isHelperTextEnabled =
!isEnoughLength || viewModel.isSecondStep.value == true
}
private fun onStepChanged(isSecondStep: Boolean) {
binding.buttonCancel.isGone = isSecondStep
if (isSecondStep) {
binding.layoutPassword.helperText = getString(R.string.repeat_password)
binding.buttonNext.setText(R.string.confirm)
} else {
binding.layoutPassword.helperText = getString(R.string.password_length_hint)
binding.buttonNext.setText(R.string.next)
}
}
private companion object {
const val MIN_PASSWORD_LENGTH = 4
}
}

View File

@@ -0,0 +1,38 @@
package org.koitharu.kotatsu.settings.protect
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.md5
class ProtectSetupViewModel(
private val settings: AppSettings
) : BaseViewModel() {
private val firstPassword = MutableStateFlow<String?>(null)
val isSecondStep = firstPassword.map {
it != null
}.asLiveDataDistinct(viewModelScope.coroutineContext)
val onPasswordSet = SingleLiveEvent<Unit>()
val onPasswordMismatch = SingleLiveEvent<Unit>()
val onClearText = SingleLiveEvent<Unit>()
fun onNextClick(password: String) {
if (firstPassword.value == null) {
firstPassword.value = password
onClearText.call(Unit)
} else {
if (firstPassword.value == password) {
settings.appPassword = password.md5()
onPasswordSet.call(Unit)
} else {
onPasswordMismatch.call(Unit)
}
}
}
}

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