Compare commits

...

24 Commits

Author SHA1 Message Date
Koitharu
8f2cf8141a Mark HenChan manga as nsfw 2021-12-10 18:10:12 +02:00
Koitharu
eefd1129f7 Update dependencies, setup FragmentStrictMode 2021-12-04 12:09:13 +02:00
Koitharu
5ed0f8b5a6 Check if category name is not empty 2021-12-03 21:11:50 +02:00
Koitharu
9b4aa4fd64 Migrate AniBel parser to graphql 2021-12-03 20:30:20 +02:00
Koitharu
bbb226791b Merge branch 'hotifx/2.0.1' into devel 2021-11-22 08:48:11 +02:00
Koitharu
30ac4435d4 Update version 2021-11-22 08:41:35 +02:00
Koitharu
1b9dfe1901 Temporary change anibel domain 2021-11-21 17:41:32 +02:00
Zakhar Timoshenko
808a6efd8f [Source] [MangaOwl] Fix not loading chapter list 2021-11-21 17:13:39 +02:00
Zakhar Timoshenko
66ed19ed5a [Source] [MangaOwl] Fix not loading chapter list 2021-11-21 17:12:04 +02:00
Koitharu
527a3cbd09 Option to exclude NSFW content from history 2021-11-20 16:49:30 +02:00
Koitharu
f22963b315 Use DownloadManager for pages saving 2021-11-18 20:06:44 +02:00
Koitharu
2ce5cb524f Increase version 2021-11-18 18:43:30 +02:00
Koitharu
4cbc6392fb Update AdapterDelegates library 2021-11-17 19:23:08 +02:00
Koitharu
049f9fa625 Fix cover image in lists 2021-11-17 19:08:13 +02:00
Koitharu
c853fae820 Fix browser activity insets 2021-11-12 20:46:25 +02:00
Koitharu
dd1d84a4fe Remanga authorization support #73 2021-11-12 20:36:01 +02:00
Koitharu
1569aa5dd5 Cleanup data classes 2021-11-12 20:13:49 +02:00
Koitharu
51cd88eded Update dependencies 2021-11-12 20:13:48 +02:00
Aliaksiej Razumaŭ
bf386deef0 Translated using Weblate (Belarusian)
Currently translated at 100.0% (244 of 244 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
2021-11-12 20:13:26 +02:00
J. Lavoie
5c80cdee81 Translated using Weblate (Spanish)
Currently translated at 98.7% (241 of 244 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
2021-11-12 20:13:26 +02:00
J. Lavoie
b29fbb37cd Translated using Weblate (Finnish)
Currently translated at 100.0% (244 of 244 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fi/
2021-11-12 20:13:26 +02:00
J. Lavoie
589831beef Translated using Weblate (French)
Currently translated at 100.0% (244 of 244 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
2021-11-12 20:13:26 +02:00
J. Lavoie
0f5d153543 Translated using Weblate (Italian)
Currently translated at 100.0% (244 of 244 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
2021-11-12 20:13:26 +02:00
J. Lavoie
ab1c99d132 Translated using Weblate (German)
Currently translated at 100.0% (244 of 244 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
2021-11-12 20:13:26 +02:00
101 changed files with 698 additions and 478 deletions

1
.idea/gradle.xml generated
View File

@@ -14,7 +14,6 @@
<option value="$PROJECT_DIR$/app" />
</set>
</option>
<option name="resolveModulePerSourceSet" value="false" />
</GradleProjectSettings>
</option>
</component>

View File

@@ -13,8 +13,8 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdkVersion 21
targetSdkVersion 31
versionCode 370
versionName '2.0-b2'
versionCode 374
versionName '2.0.2'
generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -59,8 +59,6 @@ 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'
]
}
}
@@ -69,23 +67,23 @@ dependencies {
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2'
implementation 'androidx.core:core-ktx:1.6.0'
implementation 'androidx.activity:activity-ktx:1.3.1'
implementation 'androidx.fragment:fragment-ktx:1.3.6'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.3.1'
implementation 'androidx.lifecycle:lifecycle-service:2.3.1'
implementation 'androidx.lifecycle:lifecycle-process:2.3.1'
implementation 'androidx.constraintlayout:constraintlayout:2.1.1'
implementation 'androidx.core:core-ktx:1.7.0'
implementation 'androidx.activity:activity-ktx:1.4.0'
implementation 'androidx.fragment:fragment-ktx:1.4.0'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.4.0'
implementation 'androidx.lifecycle:lifecycle-service:2.4.0'
implementation 'androidx.lifecycle:lifecycle-process:2.4.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.2'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.2.1'
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
implementation 'androidx.preference:preference-ktx:1.1.1'
implementation 'androidx.work:work-runtime-ktx:2.7.0'
implementation 'androidx.work:work-runtime-ktx:2.7.1'
implementation 'com.google.android.material:material:1.4.0'
//noinspection LifecycleAnnotationProcessorWithJava8
kapt 'androidx.lifecycle:lifecycle-compiler:2.3.1'
kapt 'androidx.lifecycle:lifecycle-compiler:2.4.0'
implementation 'androidx.room:room-runtime:2.3.0'
implementation 'androidx.room:room-ktx:2.3.0'
@@ -95,10 +93,10 @@ dependencies {
implementation 'com.squareup.okio:okio:2.10.0'
implementation 'org.jsoup:jsoup:1.14.3'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.0'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.0'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.1'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.1'
implementation 'io.insert-koin:koin-android:3.1.2'
implementation 'io.insert-koin:koin-android:3.1.4'
implementation 'io.coil-kt:coil-base:1.4.0'
implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
implementation 'com.github.solkin:disk-lru-cache:1.3'
@@ -109,7 +107,7 @@ dependencies {
testImplementation 'com.google.truth:truth:1.1.3'
testImplementation 'org.json:json:20210307'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.2'
testImplementation 'io.insert-koin:koin-test-junit4:3.1.2'
testImplementation 'io.insert-koin:koin-test-junit4:3.1.4'
androidTestImplementation 'androidx.test:runner:1.4.0'
androidTestImplementation 'androidx.test:rules:1.4.0'

View File

@@ -5,9 +5,7 @@
public static void checkReturnedValueIsNotNull(...);
public static void checkFieldIsNotNull(...);
public static void checkParameterIsNotNull(...);
public static void checkNotNullParameter(...);
}
-keep class org.koitharu.kotatsu.core.db.entity.* { *; }
-keepclassmembers public class * extends org.koitharu.kotatsu.core.parser.MangaRepository {
public <init>(...);
}
-dontwarn okhttp3.internal.platform.ConscryptPlatform

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu
import android.app.Application
import android.os.StrictMode
import androidx.appcompat.app.AppCompatDelegate
import androidx.fragment.app.strictmode.FragmentStrictMode
import org.koin.android.ext.android.get
import org.koin.android.ext.koin.androidContext
import org.koin.core.context.startKoin
@@ -65,7 +66,7 @@ class KotatsuApp : Application() {
trackerModule,
settingsModule,
readerModule,
appWidgetModule
appWidgetModule,
)
}
}
@@ -86,5 +87,13 @@ class KotatsuApp : Application() {
.penaltyLog()
.build()
)
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder()
.penaltyDeath()
.detectFragmentReuse()
.detectWrongFragmentContainer()
.detectRetainInstanceUsage()
.detectTargetFragmentUsage()
.detectSetUserVisibleHint()
.build()
}
}

View File

@@ -5,7 +5,7 @@ import android.net.Uri
import android.os.Bundle
import org.koitharu.kotatsu.core.model.Manga
data class MangaIntent(
class MangaIntent(
val manga: Manga?,
val mangaId: Long,
val uri: Uri?

View File

@@ -1,15 +1,21 @@
package org.koitharu.kotatsu.base.domain
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koitharu.kotatsu.core.exceptions.GraphQLException
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.prefs.SourceSettings
import org.koitharu.kotatsu.utils.ext.await
import org.koitharu.kotatsu.utils.ext.parseJson
open class MangaLoaderContext(
private val okHttp: OkHttpClient,
val cookieJar: CookieJar
val cookieJar: CookieJar,
) : KoinComponent {
suspend fun httpGet(url: String, headers: Headers? = null): Response {
@@ -24,7 +30,7 @@ open class MangaLoaderContext(
suspend fun httpPost(
url: String,
form: Map<String, String>
form: Map<String, String>,
): Response {
val body = FormBody.Builder()
form.forEach { (k, v) ->
@@ -38,7 +44,7 @@ open class MangaLoaderContext(
suspend fun httpPost(
url: String,
payload: String
payload: String,
): Response {
val body = FormBody.Builder()
payload.split('&').forEach {
@@ -55,10 +61,24 @@ open class MangaLoaderContext(
return okHttp.newCall(request.build()).await()
}
open fun getSettings(source: MangaSource) = SourceSettings(get(), source)
private companion object {
private const val SCHEME_HTTP = "http"
suspend fun graphQLQuery(endpoint: String, query: String): JSONObject {
val body = JSONObject()
body.put("operationName", null)
body.put("variables", JSONObject())
body.put("query", "{${query}}")
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = body.toString().toRequestBody(mediaType)
val request = Request.Builder()
.post(requestBody)
.url(endpoint)
val json = okHttp.newCall(request.build()).await().parseJson()
json.optJSONArray("errors")?.let {
if (it.length() != 0) {
throw GraphQLException(it)
}
}
return json
}
open fun getSettings(source: MangaSource) = SourceSettings(get(), source)
}

View File

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

View File

@@ -6,11 +6,10 @@ import android.text.InputFilter
import android.view.LayoutInflater
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.databinding.DialogInputBinding
class TextInputDialog private constructor(
private val delegate: AlertDialog
private val delegate: AlertDialog,
) : DialogInterface by delegate {
fun show() = delegate.show()
@@ -33,7 +32,7 @@ class TextInputDialog private constructor(
}
fun setHint(@StringRes hintResId: Int): Builder {
binding.inputLayout.hint = binding.root.context.getString(hintResId)
binding.inputEdit.hint = binding.root.context.getString(hintResId)
return this
}
@@ -64,7 +63,7 @@ class TextInputDialog private constructor(
listener: (DialogInterface, String) -> Unit
): Builder {
delegate.setPositiveButton(textId) { dialog, _ ->
listener(dialog, binding.inputEdit.text.toString().orEmpty())
listener(dialog, binding.inputEdit.text?.toString().orEmpty())
}
return this
}

View File

@@ -1,26 +0,0 @@
package org.koitharu.kotatsu.base.ui.list
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import java.util.*
@Deprecated("")
class AdapterUpdater<T>(oldList: List<T>, newList: List<T>, getId: (T) -> Long) {
private val diff = DiffUtil.calculateDiff(object : DiffUtil.Callback() {
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) =
getId(oldList[oldItemPosition]) == getId(newList[newItemPosition])
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) =
Objects.equals(oldList[oldItemPosition], newList[newItemPosition])
override fun getOldListSize() = oldList.size
override fun getNewListSize() = newList.size
})
operator fun invoke(adapter: RecyclerView.Adapter<*>) {
diff.dispatchUpdatesTo(adapter)
}
}

View File

@@ -96,11 +96,32 @@ class ChipsView @JvmOverloads constructor(
}
}
data class ChipModel(
class ChipModel(
@DrawableRes val icon: Int,
val title: CharSequence,
val data: Any? = null
)
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ChipModel
if (icon != other.icon) return false
if (title != other.title) return false
if (data != other.data) return false
return true
}
override fun hashCode(): Int {
var result = icon
result = 31 * result + title.hashCode()
result = 31 * result + data.hashCode()
return result
}
}
fun interface OnChipClickListener {

View File

@@ -6,7 +6,7 @@ import android.widget.LinearLayout
import androidx.appcompat.widget.AppCompatImageView
import androidx.core.content.withStyledAttributes
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.utils.ext.resolveAdjustedSize
import kotlin.math.roundToInt
class CoverImageView @JvmOverloads constructor(
@@ -17,47 +17,22 @@ class CoverImageView @JvmOverloads constructor(
init {
context.withStyledAttributes(attrs, R.styleable.CoverImageView, defStyleAttr) {
orientation = getInt(R.styleable.CoverImageView_android_orientation, HORIZONTAL)
orientation = getInt(R.styleable.CoverImageView_android_orientation, orientation)
}
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val w: Int
val h: Int
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val desiredWidth: Int
val desiredHeight: Int
if (orientation == VERTICAL) {
val desiredHeight = (drawable?.intrinsicHeight?.coerceAtLeast(0) ?: 0) +
paddingTop + paddingBottom
h = resolveAdjustedSize(
desiredHeight.coerceAtLeast(suggestedMinimumHeight),
maxHeight,
heightMeasureSpec
)
val desiredWidth =
(h * ASPECT_RATIO_WIDTH / ASPECT_RATIO_HEIGHT).toInt() + paddingLeft + paddingRight
w = resolveAdjustedSize(
desiredWidth.coerceAtLeast(suggestedMinimumWidth),
maxWidth,
widthMeasureSpec
)
desiredHeight = measuredHeight
desiredWidth = (desiredHeight * ASPECT_RATIO_WIDTH / ASPECT_RATIO_HEIGHT).roundToInt()
} else {
val desiredWidth = (drawable?.intrinsicWidth?.coerceAtLeast(0) ?: 0) +
paddingLeft + paddingRight
w = resolveAdjustedSize(
desiredWidth.coerceAtLeast(suggestedMinimumWidth),
maxWidth,
widthMeasureSpec
)
val desiredHeight =
(w * ASPECT_RATIO_HEIGHT / ASPECT_RATIO_WIDTH).toInt() + paddingTop + paddingBottom
h = resolveAdjustedSize(
desiredHeight.coerceAtLeast(suggestedMinimumHeight),
maxHeight,
heightMeasureSpec
)
desiredWidth = measuredWidth
desiredHeight = (desiredWidth * ASPECT_RATIO_HEIGHT / ASPECT_RATIO_WIDTH).roundToInt()
}
val widthSize = resolveSizeAndState(w, widthMeasureSpec, 0)
val heightSize = resolveSizeAndState(h, heightMeasureSpec, 0)
setMeasuredDimension(widthSize, heightSize)
setMeasuredDimension(desiredWidth, desiredHeight)
}
companion object {

View File

@@ -92,8 +92,16 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
}
override fun onWindowInsetsChanged(insets: Insets) {
binding.appbar.updatePadding(top = insets.top)
binding.webView.updatePadding(bottom = insets.bottom)
binding.appbar.updatePadding(
top = insets.top,
left = insets.left,
right = insets.right,
)
binding.root.updatePadding(
left = insets.left,
right = insets.right,
bottom = insets.bottom,
)
}
companion object {

View File

@@ -2,7 +2,7 @@ package org.koitharu.kotatsu.core.backup
import org.json.JSONArray
data class BackupEntry(
class BackupEntry(
val name: String,
val data: JSONArray
) {

View File

@@ -9,7 +9,7 @@ import org.koitharu.kotatsu.core.model.MangaState
import org.koitharu.kotatsu.core.model.MangaTag
@Entity(tableName = "manga")
data class MangaEntity(
class MangaEntity(
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id") val id: Long,
@ColumnInfo(name = "title") val title: String,

View File

@@ -14,7 +14,7 @@ import androidx.room.PrimaryKey
onDelete = ForeignKey.CASCADE
)]
)
data class MangaPrefsEntity(
class MangaPrefsEntity(
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id") val mangaId: Long,
@ColumnInfo(name = "mode") val mode: Int

View File

@@ -20,7 +20,7 @@ import androidx.room.ForeignKey
)
]
)
data class MangaTagsEntity(
class MangaTagsEntity(
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
@ColumnInfo(name = "tag_id", index = true) val tagId: Long
)

View File

@@ -5,7 +5,7 @@ import androidx.room.Junction
import androidx.room.Relation
import org.koitharu.kotatsu.utils.ext.mapToSet
data class MangaWithTags(
class MangaWithTags(
@Embedded val manga: MangaEntity,
@Relation(
parentColumn = "manga_id",

View File

@@ -16,7 +16,7 @@ import androidx.room.PrimaryKey
)
]
)
data class SuggestionEntity(
class SuggestionEntity(
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
@ColumnInfo(name = "relevance") val relevance: Float,

View File

@@ -8,7 +8,7 @@ import org.koitharu.kotatsu.core.model.MangaTag
import org.koitharu.kotatsu.utils.ext.longHashCode
@Entity(tableName = "tags")
data class TagEntity(
class TagEntity(
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "tag_id") val id: Long,
@ColumnInfo(name = "title") val title: String,

View File

@@ -15,7 +15,7 @@ import androidx.room.PrimaryKey
)
]
)
data class TrackEntity(
class TrackEntity(
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id") val mangaId: Long,
@ColumnInfo(name = "chapters_total") val totalChapters: Int,

View File

@@ -15,7 +15,7 @@ import androidx.room.PrimaryKey
)
]
)
data class TrackLogEntity(
class TrackLogEntity(
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "id") val id: Long = 0L,
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,

View File

@@ -7,7 +7,7 @@ import org.koitharu.kotatsu.core.model.TrackingLogItem
import org.koitharu.kotatsu.utils.ext.mapToSet
import java.util.*
data class TrackLogWithManga(
class TrackLogWithManga(
@Embedded val trackLog: TrackLogEntity,
@Relation(
parentColumn = "manga_id",

View File

@@ -0,0 +1,14 @@
package org.koitharu.kotatsu.core.exceptions
import org.json.JSONArray
import org.koitharu.kotatsu.utils.ext.map
class GraphQLException(private val errors: JSONArray) : RuntimeException() {
val messages = errors.map {
it.getString("message")
}
override val message: String
get() = messages.joinToString("\n")
}

View File

@@ -2,12 +2,12 @@ package org.koitharu.kotatsu.core.github
import java.util.*
data class VersionId(
class VersionId(
val major: Int,
val minor: Int,
val build: Int,
val variantType: String,
val variantNumber: Int
val variantNumber: Int,
) : Comparable<VersionId> {
override fun compareTo(other: VersionId): Int {
@@ -30,6 +30,30 @@ data class VersionId(
return variantNumber.compareTo(other.variantNumber)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as VersionId
if (major != other.major) return false
if (minor != other.minor) return false
if (build != other.build) return false
if (variantType != other.variantType) return false
if (variantNumber != other.variantNumber) return false
return true
}
override fun hashCode(): Int {
var result = major
result = 31 * result + minor
result = 31 * result + build
result = 31 * result + variantType.hashCode()
result = 31 * result + variantNumber
return result
}
companion object {
private fun variantWeight(variantType: String) =

View File

@@ -9,8 +9,13 @@ data class MangaChapter(
val name: String,
val number: Int,
val url: String,
val scanlator: String? = null,
val scanlator: String?,
val uploadDate: Long,
val branch: String? = null,
val source: MangaSource
) : Parcelable
val branch: String?,
val source: MangaSource,
) : Parcelable, Comparable<MangaChapter> {
override fun compareTo(other: MangaChapter): Int {
return number.compareTo(other.number)
}
}

View File

@@ -10,5 +10,5 @@ data class MangaHistory(
val updatedAt: Date,
val chapterId: Long,
val page: Int,
val scroll: Int
val scroll: Int,
) : Parcelable

View File

@@ -8,6 +8,6 @@ data class MangaPage(
val id: Long,
val url: String,
val referer: String,
val preview: String? = null,
val source: MangaSource
val preview: String?,
val source: MangaSource,
) : Parcelable

View File

@@ -14,7 +14,7 @@ import org.koitharu.kotatsu.local.domain.LocalMangaRepository
enum class MangaSource(
val title: String,
val locale: String?,
val cls: Class<out MangaRepository>
val cls: Class<out MangaRepository>,
) : Parcelable {
LOCAL("Local", null, LocalMangaRepository::class.java),
READMANGA_RU("ReadManga", "ru", ReadmangaRepository::class.java),

View File

@@ -7,5 +7,5 @@ import kotlinx.parcelize.Parcelize
data class MangaTag(
val title: String,
val key: String,
val source: MangaSource
val source: MangaSource,
) : Parcelable

View File

@@ -6,4 +6,5 @@ object CommonHeaders {
const val USER_AGENT = "User-Agent"
const val ACCEPT = "Accept"
const val CONTENT_DISPOSITION = "Content-Disposition"
const val COOKIE = "Cookie"
}

View File

@@ -8,6 +8,7 @@ import org.koin.dsl.bind
import org.koin.dsl.module
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.utils.CacheUtils
import org.koitharu.kotatsu.utils.DownloadManagerHelper
import java.util.concurrent.TimeUnit
val networkModule
@@ -28,4 +29,5 @@ val networkModule
}
}.build()
}
factory { DownloadManagerHelper(get(), get()) }
}

View File

@@ -1,9 +1,14 @@
package org.koitharu.kotatsu.core.parser.site
import androidx.collection.ArraySet
import org.json.JSONArray
import org.json.JSONObject
import org.koitharu.kotatsu.base.domain.MangaLoaderContext
import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.utils.ext.*
import org.koitharu.kotatsu.utils.ext.map
import org.koitharu.kotatsu.utils.ext.mapIndexed
import org.koitharu.kotatsu.utils.ext.stringIterator
import java.util.*
class AnibelRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) {
@@ -20,160 +25,235 @@ class AnibelRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
offset: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder?
sortOrder: SortOrder?,
): List<Manga> {
if (!query.isNullOrEmpty()) {
return if (offset == 0) search(query) else emptyList()
return if (offset == 0) {
search(query)
} else {
emptyList()
}
}
val page = (offset / 12f).toIntUp().inc()
val link = when {
tags.isNullOrEmpty() -> "/manga?page=$page".withDomain()
else -> tags.joinToString(
prefix = "/manga?",
postfix = "&page=$page",
separator = "&",
) { tag -> "genre[]=${tag.key}" }.withDomain()
}
val doc = loaderContext.httpGet(link).parseHtml()
val root = doc.body().select("div.manga-block") ?: parseFailed("Cannot find root")
val items = root.select("div.anime-card")
return items.mapNotNull { card ->
val href = card.selectFirst("a")?.attr("href") ?: return@mapNotNull null
val status = card.select("tr")[2].text()
val fullTitle = card.selectFirst("h1.anime-card-title")?.text()
?.substringBeforeLast('[') ?: return@mapNotNull null
val titleParts = fullTitle.splitTwoParts('/')
val filters = tags?.takeUnless { it.isEmpty() }?.joinToString(
separator = ",",
prefix = "genres: [",
postfix = "]"
) { "\"it.key\"" }.orEmpty()
val array = apiCall(
"""
getMediaList(offset: $offset, limit: 20, mediaType: manga, filters: {$filters}) {
docs {
mediaId
title {
be
alt
}
rating
poster
genres
slug
mediaType
status
}
}
""".trimIndent()
).getJSONObject("getMediaList").getJSONArray("docs")
return array.map { jo ->
val mediaId = jo.getString("mediaId")
val title = jo.getJSONObject("title")
val href = "${jo.getString("mediaType")}/${jo.getString("slug")}"
Manga(
id = generateUid(href),
title = titleParts?.first?.trim() ?: fullTitle,
coverUrl = card.selectFirst("img")?.attr("data-src")
?.withDomain().orEmpty(),
altTitle = titleParts?.second?.trim(),
id = generateUid(mediaId),
title = title.getString("be"),
coverUrl = jo.getString("poster").removePrefix("/cdn")
.withDomain("cdn") + "?width=200&height=280",
altTitle = title.getString("alt").takeUnless(String::isEmpty),
author = null,
rating = Manga.NO_RATING,
rating = jo.getDouble("rating").toFloat() / 10f,
url = href,
publicUrl = href.withDomain(),
tags = card.select("p.tupe.tag").select("a").mapNotNullToSet tags@{ x ->
MangaTag(
title = x.text(),
key = x.attr("href").ifEmpty {
return@mapNotNull null
}.substringAfterLast("="),
source = source
)
},
state = when (status) {
"выпускаецца" -> MangaState.ONGOING
"завершанае" -> MangaState.FINISHED
publicUrl = "https://${getDomain()}/${href}",
tags = jo.getJSONArray("genres").mapToTags(),
state = when (jo.getString("status")) {
"ongoing" -> MangaState.ONGOING
"finished" -> MangaState.FINISHED
else -> null
},
source = source
source = source,
)
}
}
override suspend fun getDetails(manga: Manga): Manga {
val doc = loaderContext.httpGet(manga.publicUrl).parseHtml()
val root = doc.body().select("div.container") ?: parseFailed("Cannot find root")
val (type, slug) = manga.url.split('/')
val details = apiCall(
"""
media(mediaType: $type, slug: "$slug") {
mediaId
title {
be
alt
}
description {
be
}
status
poster
rating
genres
}
""".trimIndent()
).getJSONObject("media")
val title = details.getJSONObject("title")
val poster = details.getString("poster").removePrefix("/cdn")
.withDomain("cdn")
val chapters = apiCall(
"""
chapters(mediaId: "${details.getString("mediaId")}") {
id
chapter
released
}
""".trimIndent()
).getJSONArray("chapters")
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()) ?: return@mapIndexedNotNull null
title = title.getString("be"),
altTitle = title.getString("alt"),
coverUrl = "$poster?width=200&height=280",
largeCoverUrl = poster,
description = details.getJSONObject("description").getString("be"),
rating = details.getDouble("rating").toFloat() / 10f,
tags = details.getJSONArray("genres").mapToTags(),
state = when (details.getString("status")) {
"ongoing" -> MangaState.ONGOING
"finished" -> MangaState.FINISHED
else -> null
},
chapters = chapters.map { jo ->
val number = jo.getInt("chapter")
MangaChapter(
id = generateUid(href),
name = "Глава " + a.selectFirst("a")?.text().orEmpty(),
number = i + 1,
url = href,
uploadDate = 0L,
source = source
id = generateUid(jo.getString("id")),
name = "Глава $number",
number = number,
url = "${manga.url}/read/$number",
scanlator = null,
uploadDate = jo.getLong("released"),
branch = null,
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
)
val (_, slug, _, number) = chapter.url.split('/')
val chapterJson = apiCall(
"""
chapter(slug: "$slug", chapter: $number) {
id
images {
large
thumbnail
}
}
""".trimIndent()
).getJSONObject("chapter")
val pages = chapterJson.getJSONArray("images")
val chapterUrl = "https://${getDomain()}/${chapter.url}"
return pages.mapIndexed { i, jo ->
MangaPage(
id = generateUid("${chapter.url}/$i"),
url = jo.getString("large"),
referer = chapterUrl,
preview = jo.getString("thumbnail"),
source = source,
)
}
parseFailed("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 { p ->
val a = p.selectFirst("a") ?: parseFailed("a is null")
MangaTag(
title = a.text().toCamelCase(),
key = a.attr("data-name"),
source = source
)
}
val json = apiCall(
"""
getFilters(mediaType: manga) {
genres
}
""".trimIndent()
)
val array = json.getJSONObject("getFilters").getJSONArray("genres")
return array.mapToTags()
}
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") ?: parseFailed("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('[') ?: return@mapNotNull null
val titleParts = fullTitle.splitTwoParts('/')
val json = apiCall(
"""
search(query: "$query", limit: 40) {
id
title {
be
en
}
poster
url
type
}
""".trimIndent()
)
val array = json.getJSONArray("search")
return array.map { jo ->
val mediaId = jo.getString("id")
val title = jo.getJSONObject("title")
val href = "${jo.getString("type").lowercase()}/${jo.getString("url")}"
Manga(
id = generateUid(href),
title = titleParts?.first?.trim() ?: fullTitle,
coverUrl = card.selectFirst("img")?.attr("src")
?.withDomain().orEmpty(),
altTitle = titleParts?.second?.trim(),
id = generateUid(mediaId),
title = title.getString("be"),
coverUrl = jo.getString("poster").removePrefix("/cdn")
.withDomain("cdn") + "?width=200&height=280",
altTitle = title.getString("en").takeUnless(String::isEmpty),
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").ifEmpty {
return@mapNotNull null
}.substringAfterLast("="),
source = source
)
},
state = when (status) {
"выпускаецца" -> MangaState.ONGOING
"завершанае" -> MangaState.FINISHED
else -> null
},
source = source
publicUrl = "https://${getDomain()}/${href}",
tags = emptySet(),
state = null,
source = source,
)
}
}
private suspend fun apiCall(request: String): JSONObject {
return loaderContext.graphQLQuery("https://api.${getDomain()}/", request)
.getJSONObject("data")
}
private fun JSONArray.mapToTags(): Set<MangaTag> {
fun toTitle(slug: String): String {
val builder = StringBuilder(slug)
var capitalize = true
for ((i, c) in builder.withIndex()) {
when {
c == '-' -> {
builder.setCharAt(i, ' ')
capitalize = true
}
capitalize -> {
builder.setCharAt(i, c.uppercaseChar())
capitalize = false
}
}
}
return builder.toString()
}
val result = ArraySet<MangaTag>(length())
stringIterator().forEach {
result.add(
MangaTag(
title = toTitle(it),
key = it,
source = source,
)
)
}
return result
}
}

View File

@@ -88,8 +88,10 @@ abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRe
name = tr.selectFirst("a")?.text().orEmpty(),
number = i + 1,
url = href,
scanlator = null,
branch = null,
uploadDate = dateFormat.tryParse(tr.selectFirst("div.date")?.text()),
source = source
source = source,
)
}
)
@@ -117,8 +119,9 @@ abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRe
MangaPage(
id = generateUid(url),
url = url,
preview = null,
referer = fullUrl,
source = source
source = source,
)
}
}

View File

@@ -101,7 +101,9 @@ class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
url = "$baseChapterUrl$chid",
uploadDate = it.getLong("date") * 1000,
name = if (title.isEmpty()) volChap else "$volChap: $title",
number = totalChapters - i
number = totalChapters - i,
scanlator = null,
branch = null,
)
}.reversed()
)
@@ -116,8 +118,9 @@ class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
MangaPage(
id = generateUid(jo.getLong("id")),
referer = fullUrl,
preview = null,
source = chapter.source,
url = jo.getString("img")
url = jo.getString("img"),
)
}
}

View File

@@ -143,6 +143,8 @@ class ExHentaiRepository(
url = url,
uploadDate = 0L,
source = source,
scanlator = null,
branch = null,
)
}
chapters

View File

@@ -142,7 +142,8 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
url = href,
uploadDate = dateFormat.tryParse(tr.selectFirst("td.d-none")?.text()),
scanlator = translators,
source = source
source = source,
branch = null,
)
}
)
@@ -167,8 +168,9 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
MangaPage(
id = generateUid(url),
url = url,
preview = null,
referer = chapter.url,
source = source
source = source,
)
}
}

View File

@@ -18,12 +18,10 @@ class HenChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(load
sortOrder: SortOrder?
): List<Manga> {
return super.getList2(offset, query, tags, sortOrder).map {
val cover = it.coverUrl
if (cover.contains("_blur")) {
it.copy(coverUrl = cover.replace("_blur", ""))
} else {
it
}
it.copy(
coverUrl = it.coverUrl.replace("_blur", ""),
isNsfw = true,
)
}
}
@@ -50,7 +48,9 @@ class HenChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(load
source = source,
number = 1,
uploadDate = 0L,
name = manga.title
name = manga.title,
scanlator = null,
branch = null,
)
)
)

View File

@@ -118,7 +118,8 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
item.getString("chapter_created_at").substringBefore(" ")
),
scanlator = scanlator,
name = if (nameChapter.isNullOrBlank()) fullNameChapter else "$fullNameChapter - $nameChapter"
branch = null,
name = if (nameChapter.isNullOrBlank()) fullNameChapter else "$fullNameChapter - $nameChapter",
)
)
}
@@ -178,8 +179,9 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
MangaPage(
id = generateUid(pageUrl),
url = pageUrl,
preview = null,
referer = fullUrl,
source = source
source = source,
)
}
}

View File

@@ -93,7 +93,7 @@ class MangaOwlRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposit
},
chapters = table.select("div.table.table-chapter-list").select("li.list-group-item.chapter_list").asReversed().mapIndexed { i, li ->
val a = li.select("a")
val href = a.attr("href").ifEmpty {
val href = a.attr("data-href").ifEmpty {
parseFailed("Link is missing")
}
MangaChapter(
@@ -101,8 +101,10 @@ class MangaOwlRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposit
name = a.select("label").text(),
number = i + 1,
url = href,
scanlator = null,
branch = null,
uploadDate = dateFormat.tryParse(li.selectFirst("small:last-of-type")?.text()),
source = MangaSource.MANGAOWL
source = MangaSource.MANGAOWL,
)
}
)
@@ -117,8 +119,9 @@ class MangaOwlRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposit
MangaPage(
id = generateUid(url),
url = url,
preview = null,
referer = fullUrl,
source = MangaSource.MANGAOWL
source = MangaSource.MANGAOWL,
)
}
}

View File

@@ -124,7 +124,9 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
dateFormat,
li.selectFirst("span.time")?.text()
),
name = name.ifEmpty { "${manga.title} - ${i + 1}" }
name = name.ifEmpty { "${manga.title} - ${i + 1}" },
scanlator = null,
branch = null,
)
}
)
@@ -143,8 +145,9 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
MangaPage(
id = generateUid(href),
url = href,
preview = null,
referer = fullUrl,
source = MangaSource.MANGATOWN
source = MangaSource.MANGATOWN,
)
} ?: parseFailed("Pages list not found")
}

View File

@@ -146,7 +146,9 @@ class MangareadRepository(
dateFormat,
doc2.selectFirst("span.chapter-release-date i")?.text()
),
source = MangaSource.MANGAREAD
source = MangaSource.MANGAREAD,
scanlator = null,
branch = null,
)
}
)
@@ -164,8 +166,9 @@ class MangareadRepository(
MangaPage(
id = generateUid(url),
url = url,
preview = null,
referer = fullUrl,
source = MangaSource.MANGAREAD
source = MangaSource.MANGAREAD,
)
}
}

View File

@@ -114,6 +114,8 @@ abstract class NineMangaRepository(
url = href,
uploadDate = parseChapterDateByLang(li.selectFirst("span")?.text().orEmpty()),
source = source,
scanlator = null,
branch = null,
)
}
)

View File

@@ -6,16 +6,20 @@ import org.json.JSONObject
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.MangaRepositoryAuthProvider
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.utils.ext.*
import java.text.SimpleDateFormat
import java.util.*
class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) {
class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext),
MangaRepositoryAuthProvider {
override val source = MangaSource.REMANGA
override val defaultDomain = "remanga.org"
override val authUrl: String
get() = "https://${getDomain()}/user/login"
override val sortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.UPDATED,
@@ -30,6 +34,7 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
tags: Set<MangaTag>?,
sortOrder: SortOrder?
): List<Manga> {
copyCookies()
val domain = getDomain()
val urlBuilder = StringBuilder()
.append("https://api.")
@@ -78,6 +83,7 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
}
override suspend fun getDetails(manga: Manga): Manga {
copyCookies()
val domain = getDomain()
val slug = manga.url.find(LAST_URL_PATH_REGEX)
?: throw ParseException("Cannot obtain slug from ${manga.url}")
@@ -130,7 +136,8 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
},
uploadDate = dateFormat.tryParse(jo.getString("upload_date")),
scanlator = publishers.optJSONObject(0)?.getStringOrNull("name"),
source = MangaSource.REMANGA
source = MangaSource.REMANGA,
branch = null,
)
}.asReversed()
)
@@ -164,6 +171,17 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
}
}
override fun isAuthorized(): Boolean {
return loaderContext.cookieJar.getCookies(getDomain()).any {
it.name == "user"
}
}
private fun copyCookies() {
val domain = getDomain()
loaderContext.cookieJar.copyCookies(domain, "api.$domain")
}
private fun getSortKey(order: SortOrder?) = when (order) {
SortOrder.UPDATED -> "-chapter_date"
SortOrder.POPULARITY -> "-rating"
@@ -175,8 +193,9 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
private fun parsePage(jo: JSONObject, referer: String) = MangaPage(
id = generateUid(jo.getLong("id")),
url = jo.getString("link"),
preview = null,
referer = referer,
source = source
source = source,
)
private companion object {

View File

@@ -30,7 +30,9 @@ class YaoiChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(loa
number = i + 1,
url = href,
uploadDate = 0L,
source = source
source = source,
scanlator = null,
branch = null,
)
}
)

View File

@@ -79,6 +79,8 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
var historyGrouping by BoolPreferenceDelegate(KEY_HISTORY_GROUPING, true)
var isHistoryExcludeNsfw by BoolPreferenceDelegate(KEY_HISTORY_EXCLUDE_NSFW, false)
var chaptersReverse by BoolPreferenceDelegate(KEY_REVERSE_CHAPTERS, false)
val zoomMode by EnumPreferenceDelegate(
@@ -192,6 +194,7 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
const val KEY_RESTORE = "restore"
const val KEY_HISTORY_GROUPING = "history_grouping"
const val KEY_REVERSE_CHAPTERS = "reverse_chapters"
const val KEY_HISTORY_EXCLUDE_NSFW = "history_exclude_nsfw"
// About
const val KEY_APP_UPDATE = "app_update"

View File

@@ -14,16 +14,35 @@ sealed class DateTimeAgo : ListModel {
}
}
data class MinutesAgo(val minutes: Int) : DateTimeAgo() {
class MinutesAgo(val minutes: Int) : DateTimeAgo() {
override fun format(resources: Resources): String {
return resources.getQuantityString(R.plurals.minutes_ago, minutes, minutes)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as MinutesAgo
return minutes == other.minutes
}
override fun hashCode(): Int = minutes
}
data class HoursAgo(val hours: Int) : DateTimeAgo() {
class HoursAgo(val hours: Int) : DateTimeAgo() {
override fun format(resources: Resources): String {
return resources.getQuantityString(R.plurals.hours_ago, hours, hours)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as HoursAgo
return hours == other.hours
}
override fun hashCode(): Int = hours
}
object Today : DateTimeAgo() {
@@ -38,10 +57,19 @@ sealed class DateTimeAgo : ListModel {
}
}
data class DaysAgo(val days: Int) : DateTimeAgo() {
class DaysAgo(val days: Int) : DateTimeAgo() {
override fun format(resources: Resources): String {
return resources.getQuantityString(R.plurals.days_ago, days, days)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as DaysAgo
return days == other.days
}
override fun hashCode(): Int = days
}
object LongAgo : DateTimeAgo() {

View File

@@ -44,7 +44,7 @@ import org.koitharu.kotatsu.utils.ext.getDisplayMessage
class DetailsActivity : BaseActivity<ActivityDetailsBinding>(),
TabLayoutMediator.TabConfigurationStrategy {
private val viewModel by viewModel<DetailsViewModel>(mode = LazyThreadSafetyMode.NONE) {
private val viewModel by viewModel<DetailsViewModel> {
parametersOf(MangaIntent.from(intent))
}

View File

@@ -19,10 +19,6 @@ class ChaptersAdapter(
return items[position].chapter.id
}
fun setItems(newItems: List<ChapterListItem>, callback: Runnable) {
differ.submitList(newItems, callback)
}
private class DiffCallback : DiffUtil.ItemCallback<ChapterListItem>() {
override fun areItemsTheSame(oldItem: ChapterListItem, newItem: ChapterListItem): Boolean {

View File

@@ -8,7 +8,7 @@ import org.koitharu.kotatsu.core.model.SortOrder
import java.util.*
@Entity(tableName = "favourite_categories")
data class FavouriteCategoryEntity(
class FavouriteCategoryEntity(
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "category_id") val categoryId: Int,
@ColumnInfo(name = "created_at") val createdAt: Long,

View File

@@ -21,7 +21,7 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
)
]
)
data class FavouriteEntity(
class FavouriteEntity(
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
@ColumnInfo(name = "category_id", index = true) val categoryId: Long,
@ColumnInfo(name = "created_at") val createdAt: Long

View File

@@ -7,7 +7,7 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
data class FavouriteManga(
class FavouriteManga(
@Embedded val favourite: FavouriteEntity,
@Relation(
parentColumn = "manga_id",

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.favourites.ui.categories
import android.content.Context
import android.text.InputType
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.dialog.TextInputDialog
@@ -32,7 +33,12 @@ class CategoriesEditDelegate(
.setNegativeButton(android.R.string.cancel)
.setMaxLength(MAX_TITLE_LENGTH, false)
.setPositiveButton(R.string.rename) { _, name ->
callback.onRenameCategory(category, name)
val trimmed = name.trim()
if (trimmed.isEmpty()) {
Toast.makeText(context, R.string.error_empty_name, Toast.LENGTH_SHORT).show()
} else {
callback.onRenameCategory(category, name)
}
}.create()
.show()
}
@@ -45,7 +51,12 @@ class CategoriesEditDelegate(
.setNegativeButton(android.R.string.cancel)
.setMaxLength(MAX_TITLE_LENGTH, false)
.setPositiveButton(R.string.add) { _, name ->
callback.onCreateCategory(name)
val trimmed = name.trim()
if (trimmed.isEmpty()) {
Toast.makeText(context, R.string.error_empty_name, Toast.LENGTH_SHORT).show()
} else {
callback.onCreateCategory(trimmed)
}
}.create()
.show()
}

View File

@@ -25,7 +25,7 @@ class FavouriteCategoriesDialog : BaseBottomSheet<DialogFavoriteCategoriesBindin
OnListItemClickListener<MangaCategoryItem>, CategoriesEditDelegate.CategoriesEditCallback,
View.OnClickListener {
private val viewModel by viewModel<MangaCategoriesViewModel>(mode = LazyThreadSafetyMode.NONE) {
private val viewModel by viewModel<MangaCategoriesViewModel> {
parametersOf(requireNotNull(arguments?.getParcelable<Manga>(MangaIntent.KEY_MANGA)))
}
@@ -36,7 +36,7 @@ class FavouriteCategoriesDialog : BaseBottomSheet<DialogFavoriteCategoriesBindin
override fun onInflateView(
inflater: LayoutInflater,
container: ViewGroup?
container: ViewGroup?,
) = DialogFavoriteCategoriesBinding.inflate(inflater, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

View File

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

View File

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

View File

@@ -18,14 +18,14 @@ import java.util.*
)
]
)
data class HistoryEntity(
class HistoryEntity(
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id") val mangaId: Long,
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(),
@ColumnInfo(name = "updated_at") val updatedAt: Long,
@ColumnInfo(name = "chapter_id") val chapterId: Long,
@ColumnInfo(name = "page") val page: Int,
@ColumnInfo(name = "scroll") val scroll: Float
@ColumnInfo(name = "scroll") val scroll: Float,
) {
fun toMangaHistory() = MangaHistory(

View File

@@ -7,7 +7,7 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
data class HistoryWithManga(
class HistoryWithManga(
@Embedded val history: HistoryEntity,
@Relation(
parentColumn = "manga_id",

View File

@@ -8,6 +8,7 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.ext.mapItems
@@ -16,6 +17,7 @@ import org.koitharu.kotatsu.utils.ext.mapToSet
class HistoryRepository(
private val db: MangaDatabase,
private val trackingRepository: TrackingRepository,
private val settings: AppSettings,
) {
suspend fun getList(offset: Int, limit: Int = 20): List<Manga> {
@@ -45,6 +47,9 @@ class HistoryRepository(
}
suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Int) {
if (manga.isNsfw && settings.isHistoryExcludeNsfw) {
return
}
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag)
db.withTransaction {
db.tagsDao.upsert(tags)

View File

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

View File

@@ -43,10 +43,6 @@ class MangaListAdapter(
.addDelegate(ITEM_TYPE_FILTER, currentFilterAD(onTagRemoveClick))
}
fun setItems(list: List<ListModel>, commitCallback: Runnable) {
differ.submitList(list, commitCallback)
}
private class DiffCallback : DiffUtil.ItemCallback<ListModel>() {
override fun areItemsTheSame(oldItem: ListModel, newItem: ListModel) = when {

View File

@@ -69,8 +69,9 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
MangaPage(
id = entryUri.longHashCode(),
url = entryUri,
preview = null,
referer = chapter.url,
source = MangaSource.LOCAL
source = MangaSource.LOCAL,
)
}
}
@@ -124,7 +125,9 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
number = i + 1,
source = MangaSource.LOCAL,
uploadDate = 0L,
url = uriBuilder.fragment(s).build().toString()
url = uriBuilder.fragment(s).build().toString(),
scanlator = null,
branch = null,
)
}
)

View File

@@ -19,9 +19,9 @@ import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.utils.ext.ellipsize
class LocalListFragment : MangaListFragment(), ActivityResultCallback<Uri> {
class LocalListFragment : MangaListFragment(), ActivityResultCallback<Uri?> {
override val viewModel by viewModel<LocalListViewModel>(mode = LazyThreadSafetyMode.NONE)
override val viewModel by viewModel<LocalListViewModel>()
private val importCall = registerForActivityResult(
ActivityResultContracts.OpenDocument(),
this

View File

@@ -16,9 +16,9 @@ import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.utils.MediaStoreCompat
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.resolveName
import java.io.File
import java.io.IOException
@@ -74,7 +74,7 @@ class LocalListViewModel(
launchLoadingJob {
val contentResolver = context.contentResolver
withContext(Dispatchers.IO) {
val name = MediaStoreCompat(contentResolver).getName(uri)
val name = contentResolver.resolveName(uri)
?: throw IOException("Cannot fetch name from uri: $uri")
if (!LocalMangaRepository.isFileSupported(name)) {
throw UnsupportedFileException("Unsupported file on $uri")

View File

@@ -57,10 +57,8 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
NavigationView.OnNavigationItemSelectedListener, AppBarOwner,
View.OnClickListener, View.OnFocusChangeListener, SearchSuggestionListener {
private val viewModel by viewModel<MainViewModel>(mode = LazyThreadSafetyMode.NONE)
private val searchSuggestionViewModel by viewModel<SearchSuggestionViewModel>(
mode = LazyThreadSafetyMode.NONE
)
private val viewModel by viewModel<MainViewModel>()
private val searchSuggestionViewModel by viewModel<SearchSuggestionViewModel>()
private lateinit var navHeaderBinding: NavigationHeaderBinding
private lateinit var drawerToggle: ActionBarDrawerToggle

View File

@@ -19,7 +19,7 @@ import org.koitharu.kotatsu.utils.ext.getDisplayMessage
class ProtectActivity : BaseActivity<ActivityProtectBinding>(), TextView.OnEditorActionListener,
TextWatcher, View.OnClickListener {
private val viewModel by viewModel<ProtectViewModel>(mode = LazyThreadSafetyMode.NONE)
private val viewModel by viewModel<ProtectViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

View File

@@ -13,6 +13,6 @@ val readerModule
single { PagesCache(get()) }
viewModel { params ->
ReaderViewModel(params[0], params[1], get(), get(), get(), get())
ReaderViewModel(params[0], params[1], get(), get(), get(), get(), get())
}
}

View File

@@ -55,7 +55,7 @@ class ReaderActivity : BaseFullscreenActivity<ActivityReaderBinding>(),
GridTouchHelper.OnGridTouchListener, OnPageSelectListener, ReaderConfigDialog.Callback,
ActivityResultCallback<Boolean>, ReaderControlDelegate.OnInteractionListener {
private val viewModel by viewModel<ReaderViewModel>(mode = LazyThreadSafetyMode.NONE) {
private val viewModel by viewModel<ReaderViewModel> {
parametersOf(MangaIntent.from(intent), intent?.getParcelableExtra<ReaderState>(EXTRA_STATE))
}
@@ -196,7 +196,7 @@ class ReaderActivity : BaseFullscreenActivity<ActivityReaderBinding>(),
override fun onActivityResult(result: Boolean) {
if (result) {
viewModel.saveCurrentState(reader?.getCurrentState())
viewModel.saveCurrentPage(contentResolver)
viewModel.saveCurrentPage()
}
}

View File

@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.reader.ui
import android.content.ContentResolver
import android.net.Uri
import android.util.LongSparseArray
import android.webkit.URLUtil
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.*
@@ -23,10 +22,9 @@ import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
import org.koitharu.kotatsu.utils.MediaStoreCompat
import org.koitharu.kotatsu.utils.DownloadManagerHelper
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.IgnoreErrors
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
@@ -38,7 +36,8 @@ class ReaderViewModel(
private val dataRepository: MangaDataRepository,
private val historyRepository: HistoryRepository,
private val shortcutsRepository: ShortcutsRepository,
private val settings: AppSettings
private val settings: AppSettings,
private val downloadManagerHelper: DownloadManagerHelper,
) : BaseViewModel() {
private var loadingJob: Job? = null
@@ -150,7 +149,7 @@ class ReaderViewModel(
return pages.filter { it.chapterId == chapterId }.map { it.toMangaPage() }
}
fun saveCurrentPage(resolver: ContentResolver) {
fun saveCurrentPage() {
launchJob(Dispatchers.Default) {
try {
val state = currentState.value ?: error("Undefined state")
@@ -159,13 +158,8 @@ class ReaderViewModel(
}?.toMangaPage() ?: error("Page not found")
val repo = MangaRepository(page.source)
val pageUrl = repo.getPageUrl(page)
val file = get<PagesCache>()[pageUrl] ?: error("Page not found in cache")
val uri = file.inputStream().use { input ->
val fileName = URLUtil.guessFileName(pageUrl, null, null)
MediaStoreCompat(resolver).insertImage(fileName) {
input.copyTo(it)
}
}
val downloadId = downloadManagerHelper.downloadPage(page, pageUrl)
val uri = downloadManagerHelper.awaitDownload(downloadId)
onPageSaved.postCall(uri)
} catch (e: CancellationException) {
} catch (e: Exception) {

View File

@@ -47,10 +47,6 @@ abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
viewType: Int
): H = onCreateViewHolder(parent, loader, settings, exceptionResolver)
fun setItems(items: List<ReaderPage>, callback: Runnable) {
differ.submitList(items, callback)
}
suspend fun setItems(items: List<ReaderPage>) = suspendCoroutine<Unit> { cont ->
differ.submitList(items) {
cont.resume(Unit)

View File

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

View File

@@ -18,9 +18,7 @@ import org.koitharu.kotatsu.utils.ext.showKeyboard
class SearchActivity : BaseActivity<ActivitySearchBinding>(), SearchView.OnQueryTextListener {
private val searchSuggestionViewModel by viewModel<SearchSuggestionViewModel>(
mode = LazyThreadSafetyMode.NONE
)
private val searchSuggestionViewModel by viewModel<SearchSuggestionViewModel>()
private lateinit var source: MangaSource
override fun onCreate(savedInstanceState: Bundle?) {

View File

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

View File

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

View File

@@ -1,23 +1,21 @@
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
import android.view.View
import androidx.appcompat.app.AppCompatDelegate
import androidx.preference.*
import com.google.android.material.snackbar.Snackbar
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.base.ui.dialog.StorageSelectDialog
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 org.koitharu.kotatsu.utils.ext.getStorageName
import org.koitharu.kotatsu.utils.ext.names
import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat
import java.io.File
import java.util.*
@@ -134,53 +132,4 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
settings.setStorageDir(context ?: return, file)
}
private fun enableAppProtection(preference: SwitchPreference) {
val ctx = preference.context ?: return
val cancelListener =
object : DialogInterface.OnCancelListener, DialogInterface.OnClickListener {
override fun onCancel(dialog: DialogInterface?) {
settings.appPassword = null
preference.isChecked = false
preference.isEnabled = true
}
override fun onClick(dialog: DialogInterface?, which: Int) = onCancel(dialog)
}
preference.isEnabled = false
TextInputDialog.Builder(ctx)
.setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD)
.setHint(R.string.enter_password)
.setNegativeButton(android.R.string.cancel, cancelListener)
.setOnCancelListener(cancelListener)
.setPositiveButton(android.R.string.ok) { d, password ->
if (password.isBlank()) {
cancelListener.onCancel(d)
return@setPositiveButton
}
TextInputDialog.Builder(ctx)
.setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD)
.setHint(R.string.repeat_password)
.setNegativeButton(android.R.string.cancel, cancelListener)
.setOnCancelListener(cancelListener)
.setPositiveButton(android.R.string.ok) { d2, password2 ->
if (password == password2) {
settings.appPassword = password.md5()
preference.isChecked = true
preference.isEnabled = true
} else {
cancelListener.onCancel(d2)
Snackbar.make(
listView,
R.string.passwords_mismatch,
Snackbar.LENGTH_SHORT
).show()
}
}.setTitle(preference.title)
.create()
.show()
}.setTitle(preference.title)
.create()
.show()
}
}

View File

@@ -21,7 +21,7 @@ import java.io.FileOutputStream
class BackupDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
private val viewModel by viewModel<BackupViewModel>(mode = LazyThreadSafetyMode.NONE)
private val viewModel by viewModel<BackupViewModel>()
private var backup: File? = null
private val saveFileContract =

View File

@@ -13,7 +13,7 @@ import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.prefs.AppSettings
class BackupSettingsFragment : BasePreferenceFragment(R.string.backup_restore),
ActivityResultCallback<Uri> {
ActivityResultCallback<Uri?> {
private val backupSelectCall = registerForActivityResult(
ActivityResultContracts.OpenDocument(),

View File

@@ -22,10 +22,10 @@ class RestoreDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
override fun onInflateView(
inflater: LayoutInflater,
container: ViewGroup?
container: ViewGroup?,
) = DialogProgressBinding.inflate(inflater, container, false)
private val viewModel by viewModel<RestoreViewModel>(mode = LazyThreadSafetyMode.NONE) {
private val viewModel by viewModel<RestoreViewModel> {
parametersOf(arguments?.getString(ARG_FILE)?.toUriOrNull())
}

View File

@@ -21,7 +21,7 @@ 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 val viewModel by viewModel<OnboardViewModel>()
private var isWelcome: Boolean = false
override fun onCreate(savedInstanceState: Bundle?) {

View File

@@ -17,7 +17,7 @@ import org.koitharu.kotatsu.databinding.ActivitySetupProtectBinding
class ProtectSetupActivity : BaseActivity<ActivitySetupProtectBinding>(), TextWatcher,
View.OnClickListener, TextView.OnEditorActionListener {
private val viewModel by viewModel<ProtectSetupViewModel>(mode = LazyThreadSafetyMode.NONE)
private val viewModel by viewModel<ProtectSetupViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

View File

@@ -26,7 +26,7 @@ import org.koitharu.kotatsu.utils.progress.Progress
class FeedFragment : BaseFragment<FragmentFeedBinding>(), PaginationScrollListener.Callback,
OnListItemClickListener<Manga> {
private val viewModel by viewModel<FeedViewModel>(mode = LazyThreadSafetyMode.NONE)
private val viewModel by viewModel<FeedViewModel>()
private var feedAdapter: FeedAdapter? = null
private var updateStatusSnackbar: Snackbar? = null

View File

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

View File

@@ -1,66 +0,0 @@
package org.koitharu.kotatsu.utils
import android.content.ContentResolver
import android.content.ContentValues
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
import androidx.core.database.getStringOrNull
import org.koitharu.kotatsu.BuildConfig
import java.io.OutputStream
class MediaStoreCompat(private val contentResolver: ContentResolver) {
fun insertImage(
fileName: String,
block: (OutputStream) -> Unit
): Uri? {
val name = fileName.substringBeforeLast('.')
val cv = ContentValues(7)
cv.put(MediaStore.Images.Media.DISPLAY_NAME, name)
cv.put(MediaStore.Images.Media.TITLE, name)
cv.put(
MediaStore.Images.Media.MIME_TYPE,
MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileName.substringAfterLast('.'))
)
cv.put(MediaStore.Images.Media.DATE_ADDED, System.currentTimeMillis() / 1_000)
cv.put(MediaStore.Images.Media.DATE_MODIFIED, System.currentTimeMillis())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
cv.put(MediaStore.Images.Media.IS_PENDING, 1)
}
var uri: Uri? = null
try {
uri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, cv)
contentResolver.openOutputStream(uri!!)?.use(block)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
cv.clear()
cv.put(MediaStore.Images.Media.IS_PENDING, 0)
contentResolver.update(uri, cv, null, null)
}
} catch (e: Exception) {
if (BuildConfig.DEBUG) {
e.printStackTrace()
}
uri?.let {
contentResolver.delete(it, null, null)
}
uri = null
}
return uri
}
fun getName(uri: Uri): String? =
(if (uri.scheme == "content") {
contentResolver.query(uri, null, null, null, null)?.use {
if (it.moveToFirst()) {
it.getStringOrNull(it.getColumnIndex(OpenableColumns.DISPLAY_NAME))
} else {
null
}
}
} else {
null
}) ?: uri.path?.substringAfterLast('/')
}

View File

@@ -1,10 +1,13 @@
package org.koitharu.kotatsu.utils.ext
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.os.storage.StorageManager
import android.provider.OpenableColumns
import androidx.core.database.getStringOrNull
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R
@@ -60,4 +63,19 @@ fun Uri.toFileOrNull() = if (scheme == "file") path?.let(::File) else null
suspend fun File.deleteAwait() = withContext(Dispatchers.IO) {
delete()
}
fun ContentResolver.resolveName(uri: Uri): String? {
val fallback = uri.lastPathSegment
if (uri.scheme != "content") {
return fallback
}
query(uri, null, null, null, null)?.use {
if (it.moveToFirst()) {
it.getStringOrNull(it.getColumnIndex(OpenableColumns.DISPLAY_NAME))?.let { name ->
return name
}
}
}
return fallback
}

View File

@@ -44,6 +44,8 @@ fun JSONObject.getLongOrDefault(name: String, defaultValue: Long): Long = opt(na
operator fun JSONArray.iterator(): Iterator<JSONObject> = JSONIterator(this)
fun JSONArray.stringIterator(): Iterator<String> = JSONStringIterator(this)
private class JSONIterator(private val array: JSONArray) : Iterator<JSONObject> {
private val total = array.length()
@@ -52,7 +54,16 @@ private class JSONIterator(private val array: JSONArray) : Iterator<JSONObject>
override fun hasNext() = index < total - 1
override fun next(): JSONObject = array.getJSONObject(index++)
}
private class JSONStringIterator(private val array: JSONArray) : Iterator<String> {
private val total = array.length()
private var index = 0
override fun hasNext() = index < total - 1
override fun next(): String = array.getString(index++)
}
fun <T> JSONArray.mapToSet(block: (JSONObject) -> T): Set<T> {

View File

@@ -122,7 +122,7 @@ fun ByteArray.byte2HexFormatted(): String {
if (l > 2) {
h = h.substring(l - 2, l)
}
str.append(h.toUpperCase(Locale.ROOT))
str.append(h.uppercase(Locale.ROOT))
if (i < size - 1) {
str.append(':')
}

View File

@@ -27,9 +27,10 @@ import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.widget.shelf.adapter.CategorySelectAdapter
import org.koitharu.kotatsu.widget.shelf.model.CategoryItem
class ShelfConfigActivity : BaseActivity<ActivityCategoriesBinding>(), OnListItemClickListener<CategoryItem> {
class ShelfConfigActivity : BaseActivity<ActivityCategoriesBinding>(),
OnListItemClickListener<CategoryItem> {
private val viewModel by viewModel<ShelfConfigViewModel>(mode = LazyThreadSafetyMode.NONE)
private val viewModel by viewModel<ShelfConfigViewModel>()
private lateinit var adapter: CategorySelectAdapter
private lateinit var config: AppWidgetConfig

View File

@@ -11,7 +11,10 @@
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/inputLayout"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
app:boxBackgroundMode="filled"
app:boxBackgroundColor="@android:color/transparent"
app:hintEnabled="false"
app:expandedHintEnabled="true"
android:layout_width="match_parent"
android:layout_height="wrap_content">
@@ -21,7 +24,7 @@
android:layout_height="wrap_content"
android:imeOptions="actionDone"
android:singleLine="true"
tools:text="@tools:sample/lorem[2]" />
tools:hint="@tools:sample/lorem[2]" />
</com.google.android.material.textfield.TextInputLayout>

View File

@@ -11,15 +11,15 @@
<com.google.android.material.card.MaterialCardView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_height="match_parent"
android:layout_margin="8dp"
app:cardCornerRadius="4dp"
app:cardElevation="4dp">
<org.koitharu.kotatsu.base.ui.widgets.CoverImageView
android:id="@+id/imageView_cover"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:orientation="vertical"
android:scaleType="centerCrop"
tools:src="@tools:sample/backgrounds/scenic" />

View File

@@ -10,7 +10,7 @@
<com.google.android.material.card.MaterialCardView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_height="match_parent"
android:layout_margin="8dp"
app:cardElevation="4dp">

View File

@@ -242,4 +242,6 @@
<string name="genres">Жанры</string>
<string name="state_finished">Завершана</string>
<string name="state_ongoing">Ангоінг</string>
<string name="date_format">Фармат даты</string>
<string name="system_default">Па змаўчанні</string>
</resources>

View File

@@ -242,4 +242,6 @@
<string name="auth_complete">Autorisierung abgeschlossen</string>
<string name="state_finished">Beendet</string>
<string name="state_ongoing">Fortlaufend</string>
<string name="date_format">Datumsformat</string>
<string name="system_default">Standard</string>
</resources>

View File

@@ -239,4 +239,6 @@
<string name="chapter_is_missing_text">Este capítulo no aparece en su dispositivo. Descárguelo o léalo en línea.</string>
<string name="auth_complete">Autorización completa</string>
<string name="about_feedback_4pda">Tema sobre 4PDA</string>
<string name="date_format">Formato de la fecha</string>
<string name="system_default">Por defecto</string>
</resources>

View File

@@ -242,4 +242,6 @@
<string name="enter_password">Syötä salasana</string>
<string name="state_finished">Päättynyt</string>
<string name="state_ongoing">Jatkuva</string>
<string name="date_format">Päivämäärän muoto</string>
<string name="system_default">Oletus</string>
</resources>

View File

@@ -242,4 +242,6 @@
<string name="auth_complete">Autorisation complète</string>
<string name="state_finished">Terminé</string>
<string name="state_ongoing">En cours</string>
<string name="date_format">Format de la date</string>
<string name="system_default">Par défaut</string>
</resources>

View File

@@ -242,4 +242,6 @@
<string name="auth_complete">Autorizzazione completa</string>
<string name="state_finished">Finito</string>
<string name="state_ongoing">In corso</string>
<string name="system_default">Predefinito</string>
<string name="date_format">Formato della data</string>
</resources>

View File

@@ -243,4 +243,6 @@
<string name="state_ongoing">Онгоинг</string>
<string name="date_format">Формат даты</string>
<string name="system_default">По умолчанию</string>
<string name="exclude_nsfw_from_history">Исключить NSFW мангу из истории</string>
<string name="error_empty_name">Имя не может быть пустым</string>
</resources>

View File

@@ -244,4 +244,6 @@
<string name="state_ongoing">Ongoing</string>
<string name="date_format">Date format</string>
<string name="system_default">Default</string>
<string name="exclude_nsfw_from_history">Exclude NSFW manga from history</string>
<string name="error_empty_name">Name sould not be empty</string>
</resources>

View File

@@ -15,6 +15,11 @@
android:title="@string/clear_updates_feed"
app:iconSpaceReserved="false" />
<SwitchPreferenceCompat
android:key="history_exclude_nsfw"
android:title="@string/exclude_nsfw_from_history"
app:iconSpaceReserved="false" />
<PreferenceCategory
android:title="@string/cache"
app:iconSpaceReserved="false">

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