Compare commits

..

49 Commits
v1.0 ... v1.1

Author SHA1 Message Date
Koitharu
873b41e4f9 Fix default languages selector 2021-06-26 15:15:53 +03:00
Koitharu
f0d4deffd7 Fix Mangaread parser 2021-06-26 14:49:13 +03:00
Koitharu
b6c50d59ed #35 Fix Mangaread parser 2021-06-24 07:27:07 +03:00
Koitharu
9fcc19ef7e Fix filename transliteration 2021-06-23 19:34:10 +03:00
Koitharu
b90ebdabf9 Fix storage dirs enumeration 2021-06-21 17:53:04 +03:00
Koitharu
e08a4cf1b2 Fix crash if title is empty 2021-06-21 17:50:16 +03:00
Koitharu
bd4efcf110 Remove old search option from menu 2021-06-21 17:32:23 +03:00
Koitharu
83a9570961 Prompt before search history clear 2021-06-20 16:04:12 +03:00
Koitharu
973a4073f0 Fix crash in history settings 2021-06-20 15:59:24 +03:00
Koitharu
867812b8e3 Update koin 2021-06-20 15:17:48 +03:00
Koitharu
cf7341b065 Update translations 2021-06-20 14:55:22 +03:00
Koitharu
8c2bc078e5 Manga languages onboarding 2021-06-17 19:45:36 +03:00
Koitharu
cd7d6d7674 New search suggestion UI 2021-06-03 19:15:57 +03:00
Koitharu
bc0c5ac71a Fix Anibel titles 2021-05-23 17:17:08 +03:00
Koitharu
91619cc259 NineManga sources #14 2021-05-23 16:58:45 +03:00
Koitharu
f0c9c61b49 Fix issues with spanish translation 2021-05-20 08:12:09 +03:00
Koitharu
d65158b7b9 Update Kotlin to 1.5 and dependencies 2021-05-20 08:09:35 +03:00
Koitharu
4e5de1e33e Merge pull request #32 from ztimms73/devel
Fix some problems from issue #23
2021-05-19 06:51:49 +03:00
ztimms73
b009a6423d Fix some problems from issue #23 2021-05-18 11:17:00 +03:00
Koitharu
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
ztimms73
4d535cef41 Avoid searching for anime on Anibel 2021-05-15 22:51:45 +03:00
ztimms73
98147d0a81 Fix Anibel issues 2021-05-15 22:05:32 +03:00
ztimms73
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
ztimms73
51dc2ac046 Added the option to hide or not toolbar when scrolling 2021-05-15 08:56:03 +03:00
ztimms73
24d9a49420 Merge remote-tracking branch 'origin/devel' into devel 2021-05-14 22:45:07 +03:00
ztimms73
46891aa958 Minor UI changes 2021-05-14 22:43:14 +03:00
ztimms73
d1921193f0 Add Anibel source 2021-05-14 22:42:43 +03:00
Koitharu
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
Koitharu
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
Koitharu
0e74d6e017 Merge branch 'master' into devel 2021-05-03 18:18:55 +03:00
Koitharu
7690a29efb Add f-droid link to readme 2021-05-03 18:16:33 +03:00
Koitharu
b1d6f5debd Fix onApplyWindowInsets infinite call 2021-04-29 08:06:37 +03:00
Koitharu
fbb92005a1 Update database structure 2021-04-29 07:57:43 +03:00
Koitharu
5b9922d509 Remove unused code 2021-04-29 07:24:26 +03:00
Koitharu
49eebdf554 Specify LazyThreadSafetyMode for inject and viewModel delegates 2021-04-27 19:52:20 +03:00
Koitharu
2da941d550 Update dependencies 2021-04-27 19:41:41 +03:00
Koitharu
5a8d7531bf Ui fixes 2021-04-11 12:20:12 +03:00
Koitharu
4d1f5e22d3 #21 Fix cloudflare passing 2021-04-11 11:33:04 +03:00
Koitharu
012416c881 Improve password protection 2021-04-11 11:24:50 +03:00
Koitharu
0f48ad07a3 Support no-fullheight pages in webtoon mode 2021-04-04 11:18:12 +03:00
Koitharu
64752da948 Optimize global search 2021-04-02 15:26:27 +03:00
Koitharu
95148a1071 Add distributionSha256Sum for gradle wrapper 2021-03-30 07:49:10 +03:00
Koitharu
d9d0656ef4 Update dependencies and gradle 2021-03-29 20:00:05 +03:00
Koitharu
b17d8efa5c Update Koin to 3.0 2021-03-29 19:18:08 +03:00
Koitharu
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>
</AndroidXmlCodeStyleSettings>
<JetCodeStyleSettings>
<option name="ALLOW_TRAILING_COMMA" value="true" />
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="CMake">

View File

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

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
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

View File

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

View File

@@ -78,6 +78,10 @@
android:label="@string/search" />
<activity
android:name="org.koitharu.kotatsu.main.ui.protect.ProtectActivity"
android:noHistory="true"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".settings.protect.ProtectSetupActivity"
android:windowSoftInputMode="adjustResize" />
<service
@@ -127,9 +131,11 @@
android:resource="@xml/widget_recent" />
</receiver>
<meta-data android:name="android.webkit.WebView.EnableSafeBrowsing"
<meta-data
android:name="android.webkit.WebView.EnableSafeBrowsing"
android:value="false" />
<meta-data android:name="android.webkit.WebView.MetricsOptOut"
<meta-data
android:name="android.webkit.WebView.MetricsOptOut"
android:value="true" />
</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.uiModule
import org.koitharu.kotatsu.details.detailsModule
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.favouritesModule
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.history.historyModule
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.local.localModule
import org.koitharu.kotatsu.main.mainModule
import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
import org.koitharu.kotatsu.reader.readerModule
import org.koitharu.kotatsu.remotelist.remoteListModule
import org.koitharu.kotatsu.search.searchModule
@@ -36,28 +35,15 @@ class KotatsuApp : Application() {
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectAll()
.penaltyLog()
.build()
)
StrictMode.setVmPolicy(
StrictMode.VmPolicy.Builder()
.detectAll()
.setClassInstanceLimit(LocalMangaRepository::class.java, 1)
.setClassInstanceLimit(PagesCache::class.java, 1)
.setClassInstanceLimit(MangaLoaderContext::class.java, 1)
.penaltyLog()
.build()
)
enableStrictMode()
}
initKoin()
Thread.setDefaultUncaughtExceptionHandler(AppCrashHandler(applicationContext))
AppCompatDelegate.setDefaultNightMode(get<AppSettings>().theme)
registerActivityLifecycleCallbacks(get<AppProtectHelper>())
val widgetUpdater = WidgetUpdater(applicationContext)
FavouritesRepository.subscribe(widgetUpdater)
HistoryRepository.subscribe(widgetUpdater)
widgetUpdater.subscribeToFavourites(get())
widgetUpdater.subscribeToHistory(get())
}
private fun initKoin() {
@@ -83,4 +69,22 @@ class KotatsuApp : Application() {
)
}
}
private fun enableStrictMode() {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectAll()
.penaltyLog()
.build()
)
StrictMode.setVmPolicy(
StrictMode.VmPolicy.Builder()
.detectAll()
.setClassInstanceLimit(LocalMangaRepository::class.java, 1)
.setClassInstanceLimit(PagesCache::class.java, 1)
.setClassInstanceLimit(MangaLoaderContext::class.java, 1)
.penaltyLog()
.build()
)
}
}

View File

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

View File

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

View File

@@ -15,6 +15,8 @@ import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets
import androidx.core.view.*
import androidx.viewbinding.ViewBinding
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.appbar.AppBarLayout.LayoutParams.*
import org.koin.android.ext.android.get
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
@@ -27,11 +29,12 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(), OnApplyWindo
protected lateinit var binding: B
private set
protected val exceptionResolver by lazy(LazyThreadSafetyMode.NONE) {
ExceptionResolver(this, supportFragmentManager)
}
private var lastInsets: Insets = Insets.NONE
override fun onCreate(savedInstanceState: Bundle?) {
if (get<AppSettings>().isAmoledTheme) {
setTheme(R.style.AppTheme_Amoled)
@@ -56,11 +59,23 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(), OnApplyWindo
this.binding = binding
super.setContentView(binding.root)
(binding.root.findViewById<View>(R.id.toolbar) as? Toolbar)?.let(this::setSupportActionBar)
val params = (binding.root.findViewById<View>(R.id.toolbar) as? Toolbar)?.layoutParams as AppBarLayout.LayoutParams
ViewCompat.setOnApplyWindowInsetsListener(binding.root, this)
if (get<AppSettings>().isToolbarHideWhenScrolling) {
params.scrollFlags = SCROLL_FLAG_SCROLL or SCROLL_FLAG_ENTER_ALWAYS
} else {
params.scrollFlags = SCROLL_FLAG_NO_SCROLL
}
}
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
onWindowInsetsChanged(insets.getInsets(WindowInsetsCompat.Type.systemBars()))
val baseInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime())
val newInsets = Insets.max(baseInsets, imeInsets)
if (newInsets != lastInsets) {
onWindowInsetsChanged(newInsets)
lastInsets = newInsets
}
return insets
}

View File

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

View File

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

View File

@@ -6,7 +6,6 @@ import android.view.LayoutInflater
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.databinding.DialogCheckboxBinding
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 delegate = MaterialAlertDialogBuilder(context)
private val delegate = AlertDialog.Builder(context)
.setView(binding.root)
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) {
private val adapter = VolumesAdapter(context)
private val delegate = MaterialAlertDialogBuilder(context)
private val delegate = AlertDialog.Builder(context)
init {
if (adapter.isEmpty) {

View File

@@ -19,7 +19,7 @@ class TextInputDialog private constructor(
private val binding = DialogInputBinding.inflate(LayoutInflater.from(context))
private val delegate = MaterialAlertDialogBuilder(context)
private val delegate = AlertDialog.Builder(context)
.setView(binding.root)
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)
return super.onCreateOptionsMenu(menu)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -35,7 +35,7 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
View.OnLongClickListener {
private val viewModel by sharedViewModel<DetailsViewModel>()
private val coil by inject<ImageLoader>()
private val coil by inject<ImageLoader>(mode = LazyThreadSafetyMode.NONE)
private var tagsJob: Job? = null
override fun onInflateView(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ package org.koitharu.kotatsu.favourites.ui.categories
import android.content.Context
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.base.ui.dialog.TextInputDialog
import org.koitharu.kotatsu.core.model.FavouriteCategory
@@ -13,7 +13,7 @@ class CategoriesEditDelegate(
) {
fun deleteCategory(category: FavouriteCategory) {
MaterialAlertDialogBuilder(context)
AlertDialog.Builder(context)
.setMessage(context.getString(R.string.category_delete_confirm, category.title))
.setTitle(R.string.remove_category)
.setNegativeButton(android.R.string.cancel, null)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -175,10 +175,12 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
}
fun getAvailableStorageDirs(context: Context): List<File> {
val result = ArrayList<File>(5)
result += context.filesDir.sub(DIR_NAME)
val result = ArrayList<File?>(5)
result += File(context.filesDir, DIR_NAME)
result += context.getExternalFilesDirs(DIR_NAME)
return result.distinctBy { it.canonicalPath }.filter { it.exists() || it.mkdir() }
return result.filterNotNull()
.distinctBy { it.canonicalPath }
.filter { it.exists() || it.mkdir() }
}
fun getFallbackStorageDir(context: Context): File? {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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 androidx.activity.ComponentActivity
import androidx.annotation.MainThread
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
@@ -77,7 +77,7 @@ class AppUpdateChecker(private val activity: ComponentActivity) {
@MainThread
private fun showUpdateDialog(version: AppVersion) {
MaterialAlertDialogBuilder(activity)
AlertDialog.Builder(activity)
.setTitle(R.string.app_update_available)
.setMessage(buildString {
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.view.View
import androidx.appcompat.app.AlertDialog
import androidx.preference.Preference
import com.google.android.material.snackbar.Snackbar
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.prefs.AppSettings
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.utils.CacheUtils
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) {
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?) {
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 ->
viewLifecycleScope.launchWhenResumed {
val items = MangaSuggestionsProvider.getItemsCount(pref.context)
val items = searchRepository.getSearchHistoryCount()
pref.summary =
pref.context.resources.getQuantityString(R.plurals.items, items, items)
}
@@ -86,16 +88,7 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
true
}
AppSettings.KEY_SEARCH_HISTORY_CLEAR -> {
viewLifecycleScope.launch {
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()
}
clearSearchHistory(preference)
true
}
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
import android.content.DialogInterface
import android.content.Intent
import android.content.SharedPreferences
import android.os.Bundle
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.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.settings.protect.ProtectSetupActivity
import org.koitharu.kotatsu.utils.ext.*
import java.io.File
@@ -71,12 +73,19 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
AppSettings.KEY_THEME_AMOLED -> {
findPreference<Preference>(key)?.setSummary(R.string.restart_required)
}
AppSettings.KEY_HIDE_TOOLBAR -> {
findPreference<SwitchPreference>(key)?.setSummary(R.string.restart_required)
}
AppSettings.KEY_LOCAL_STORAGE -> {
findPreference<Preference>(key)?.run {
summary = settings.getStorageDir(context)?.getStorageName(context)
?: 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
}
AppSettings.KEY_PROTECT_APP -> {
if ((preference as? SwitchPreference ?: return false).isChecked) {
enableAppProtection(preference)
val pref = (preference as? SwitchPreference ?: return false)
if (pref.isChecked) {
pref.isChecked = false
startActivity(Intent(preference.context, ProtectSetupActivity::class.java))
} else {
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.settings.backup.BackupViewModel
import org.koitharu.kotatsu.settings.backup.RestoreViewModel
import org.koitharu.kotatsu.settings.onboard.OnboardViewModel
import org.koitharu.kotatsu.settings.protect.ProtectSetupViewModel
val settingsModule
get() = module {
@@ -18,5 +20,9 @@ val settingsModule
single { AppSettings(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 androidx.appcompat.app.AlertDialog
import androidx.core.view.isVisible
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.AlertDialogFragment
@@ -18,7 +17,7 @@ import java.io.File
class BackupDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
private val viewModel by viewModel<BackupViewModel>()
private val viewModel by viewModel<BackupViewModel>(mode = LazyThreadSafetyMode.NONE)
override fun onInflateView(
inflater: LayoutInflater,
@@ -41,7 +40,7 @@ class BackupDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
}
private fun onError(e: Throwable) {
MaterialAlertDialogBuilder(context ?: return)
AlertDialog.Builder(context ?: return)
.setNegativeButton(R.string.close, null)
.setTitle(R.string.error)
.setMessage(e.getDisplayMessage(resources))

View File

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