Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
beba4f029a | ||
|
|
7cf7a62881 | ||
|
|
c1e84715fb | ||
|
|
a3cc5726ee | ||
|
|
3023c02f12 | ||
|
|
efff034dc6 | ||
|
|
2bb5673446 | ||
|
|
0983885fa2 | ||
|
|
4449996a91 | ||
|
|
9cf496b7c4 | ||
|
|
4fb1db47ab | ||
|
|
14b89fbee2 | ||
|
|
8291c55fc9 | ||
|
|
46ddcb7518 |
@@ -19,16 +19,80 @@ android {
|
||||
applicationId 'org.koitharu.kotatsu'
|
||||
minSdk = 21
|
||||
targetSdk = 35
|
||||
versionCode = 1009
|
||||
versionName = '8.1.3'
|
||||
versionCode = 1012
|
||||
versionName = '8.1.6'
|
||||
generatedDensities = []
|
||||
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
||||
ksp {
|
||||
arg('room.generateKotlin', 'true')
|
||||
}
|
||||
androidResources {
|
||||
generateLocaleConfig true
|
||||
generateLocaleConfig false
|
||||
}
|
||||
resourceConfigurations += [
|
||||
"en",
|
||||
"ab",
|
||||
"ar",
|
||||
"arq",
|
||||
"as",
|
||||
"be",
|
||||
"bn",
|
||||
"ca",
|
||||
"cs",
|
||||
"de",
|
||||
"el",
|
||||
"en-rGB",
|
||||
"enm",
|
||||
"es",
|
||||
"et",
|
||||
"eu",
|
||||
"fa",
|
||||
"fi",
|
||||
"fil",
|
||||
"fr",
|
||||
"frp",
|
||||
"gu",
|
||||
"hi",
|
||||
"hr",
|
||||
"hu",
|
||||
"in",
|
||||
"it",
|
||||
"iw",
|
||||
"ja",
|
||||
"kk",
|
||||
"km",
|
||||
"ko",
|
||||
"lt",
|
||||
"lv",
|
||||
"lzh",
|
||||
"ml",
|
||||
"ms",
|
||||
"my",
|
||||
"nb-rNO",
|
||||
"ne",
|
||||
"nn",
|
||||
"or",
|
||||
"pa",
|
||||
"pa-rPK",
|
||||
"pl",
|
||||
"pt",
|
||||
"pt-rBR",
|
||||
"ro",
|
||||
"ru",
|
||||
"si",
|
||||
"sr",
|
||||
"sv",
|
||||
"ta",
|
||||
"th",
|
||||
"tr",
|
||||
"uk",
|
||||
"vi",
|
||||
"zh-rCN",
|
||||
"zh-rTW",
|
||||
// Specific BCP 47 locales
|
||||
"b+zh+Hans+MO",
|
||||
"b+zh+Hant+MO"
|
||||
]
|
||||
}
|
||||
buildTypes {
|
||||
debug {
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
android:hasFragileUserData="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:localeConfig="@xml/locales_config"
|
||||
android:largeHeap="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
|
||||
@@ -17,9 +17,9 @@ abstract class BookmarksDao {
|
||||
|
||||
@Transaction
|
||||
@Query(
|
||||
"SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY percent",
|
||||
"SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY percent LIMIT :limit OFFSET :offset",
|
||||
)
|
||||
abstract suspend fun findAll(): Map<MangaWithTags, List<BookmarkEntity>>
|
||||
abstract suspend fun findAll(offset: Int, limit: Int): Map<MangaWithTags, List<BookmarkEntity>>
|
||||
|
||||
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page = :page ORDER BY percent")
|
||||
abstract fun observe(mangaId: Long, chapterId: Long, page: Int): Flow<BookmarkEntity?>
|
||||
|
||||
@@ -28,7 +28,7 @@ class BackupRepository @Inject constructor(
|
||||
var offset = 0
|
||||
val entry = BackupEntry(BackupEntry.Name.HISTORY, JSONArray())
|
||||
while (true) {
|
||||
val history = db.getHistoryDao().findAll(offset, PAGE_SIZE)
|
||||
val history = db.getHistoryDao().findAll(offset = offset, limit = PAGE_SIZE)
|
||||
if (history.isEmpty()) {
|
||||
break
|
||||
}
|
||||
@@ -59,7 +59,7 @@ class BackupRepository @Inject constructor(
|
||||
var offset = 0
|
||||
val entry = BackupEntry(BackupEntry.Name.FAVOURITES, JSONArray())
|
||||
while (true) {
|
||||
val favourites = db.getFavouritesDao().findAllRaw(offset, PAGE_SIZE)
|
||||
val favourites = db.getFavouritesDao().findAllRaw(offset = offset, limit = PAGE_SIZE)
|
||||
if (favourites.isEmpty()) {
|
||||
break
|
||||
}
|
||||
@@ -78,19 +78,26 @@ class BackupRepository @Inject constructor(
|
||||
}
|
||||
|
||||
suspend fun dumpBookmarks(): BackupEntry {
|
||||
var offset = 0
|
||||
val entry = BackupEntry(BackupEntry.Name.BOOKMARKS, JSONArray())
|
||||
val all = db.getBookmarksDao().findAll()
|
||||
for ((m, b) in all) {
|
||||
val json = JSONObject()
|
||||
val manga = JsonSerializer(m.manga).toJson()
|
||||
json.put("manga", manga)
|
||||
val tags = JSONArray()
|
||||
m.tags.forEach { tags.put(JsonSerializer(it).toJson()) }
|
||||
json.put("tags", tags)
|
||||
val bookmarks = JSONArray()
|
||||
b.forEach { bookmarks.put(JsonSerializer(it).toJson()) }
|
||||
json.put("bookmarks", bookmarks)
|
||||
entry.data.put(json)
|
||||
while (true) {
|
||||
val bookmarks = db.getBookmarksDao().findAll(offset = offset, limit = PAGE_SIZE)
|
||||
if (bookmarks.isEmpty()) {
|
||||
break
|
||||
}
|
||||
offset += bookmarks.size
|
||||
for ((m, b) in bookmarks) {
|
||||
val json = JSONObject()
|
||||
val manga = JsonSerializer(m.manga).toJson()
|
||||
json.put("manga", manga)
|
||||
val tags = JSONArray()
|
||||
m.tags.forEach { tags.put(JsonSerializer(it).toJson()) }
|
||||
json.put("tags", tags)
|
||||
val bookmarks = JSONArray()
|
||||
b.forEach { bookmarks.put(JsonSerializer(it).toJson()) }
|
||||
json.put("bookmarks", bookmarks)
|
||||
entry.data.put(json)
|
||||
}
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
@@ -2,18 +2,22 @@ package org.koitharu.kotatsu.core.image
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.core.graphics.createBitmap
|
||||
import androidx.core.graphics.scale
|
||||
import coil3.ImageLoader
|
||||
import coil3.asImage
|
||||
import coil3.decode.DecodeResult
|
||||
import coil3.decode.DecodeUtils
|
||||
import coil3.decode.Decoder
|
||||
import coil3.decode.ImageSource
|
||||
import coil3.fetch.SourceFetchResult
|
||||
import coil3.request.Options
|
||||
import coil3.request.maxBitmapSize
|
||||
import coil3.util.component1
|
||||
import coil3.util.component2
|
||||
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import org.aomedia.avif.android.AvifDecoder
|
||||
import org.aomedia.avif.android.AvifDecoder.Info
|
||||
import org.koitharu.kotatsu.core.util.ext.toByteBuffer
|
||||
import org.koitharu.kotatsu.core.util.ext.readByteBuffer
|
||||
|
||||
class AvifImageDecoder(
|
||||
private val source: ImageSource,
|
||||
@@ -21,27 +25,52 @@ class AvifImageDecoder(
|
||||
) : Decoder {
|
||||
|
||||
override suspend fun decode(): DecodeResult = runInterruptible {
|
||||
val bytes = source.source().use {
|
||||
it.inputStream().toByteBuffer()
|
||||
}
|
||||
val info = Info()
|
||||
if (!AvifDecoder.getInfo(bytes, bytes.remaining(), info)) {
|
||||
throw ImageDecodeException(
|
||||
null,
|
||||
"avif",
|
||||
"Requested to decode byte buffer which cannot be handled by AvifDecoder",
|
||||
)
|
||||
}
|
||||
val config = if (info.depth == 8 || info.alphaPresent) Bitmap.Config.ARGB_8888 else Bitmap.Config.RGB_565
|
||||
val bitmap = createBitmap(info.width, info.height, config)
|
||||
if (!AvifDecoder.decode(bytes, bytes.remaining(), bitmap)) {
|
||||
bitmap.recycle()
|
||||
throw ImageDecodeException(null, "avif")
|
||||
}
|
||||
DecodeResult(
|
||||
image = bitmap.asImage(),
|
||||
isSampled = false,
|
||||
val bytes = source.source().readByteBuffer()
|
||||
val decoder = AvifDecoder.create(bytes) ?: throw ImageDecodeException(
|
||||
uri = source.fileOrNull()?.toString(),
|
||||
format = "avif",
|
||||
message = "Requested to decode byte buffer which cannot be handled by AvifDecoder",
|
||||
)
|
||||
try {
|
||||
val config = if (decoder.depth == 8 || decoder.alphaPresent) {
|
||||
Bitmap.Config.ARGB_8888
|
||||
} else {
|
||||
Bitmap.Config.RGB_565
|
||||
}
|
||||
val bitmap = createBitmap(decoder.width, decoder.height, config)
|
||||
val result = decoder.nextFrame(bitmap)
|
||||
if (result != 0) {
|
||||
bitmap.recycle()
|
||||
throw ImageDecodeException(
|
||||
uri = source.fileOrNull()?.toString(),
|
||||
format = "avif",
|
||||
message = AvifDecoder.resultToString(result),
|
||||
)
|
||||
}
|
||||
// downscaling
|
||||
val (dstWidth, dstHeight) = DecodeUtils.computeDstSize(
|
||||
srcWidth = bitmap.width,
|
||||
srcHeight = bitmap.height,
|
||||
targetSize = options.size,
|
||||
scale = options.scale,
|
||||
maxSize = options.maxBitmapSize,
|
||||
)
|
||||
if (dstWidth < bitmap.width || dstHeight < bitmap.height) {
|
||||
val scaled = bitmap.scale(dstWidth, dstHeight)
|
||||
bitmap.recycle()
|
||||
DecodeResult(
|
||||
image = scaled.asImage(),
|
||||
isSampled = true,
|
||||
)
|
||||
} else {
|
||||
DecodeResult(
|
||||
image = bitmap.asImage(),
|
||||
isSampled = false,
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
decoder.release()
|
||||
}
|
||||
}
|
||||
|
||||
class Factory : Decoder.Factory {
|
||||
|
||||
@@ -9,12 +9,15 @@ import androidx.annotation.RequiresApi
|
||||
import androidx.core.graphics.createBitmap
|
||||
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
|
||||
import okio.IOException
|
||||
import okio.buffer
|
||||
import okio.source
|
||||
import org.aomedia.avif.android.AvifDecoder
|
||||
import org.aomedia.avif.android.AvifDecoder.Info
|
||||
import org.jetbrains.annotations.Blocking
|
||||
import org.koitharu.kotatsu.core.util.MimeTypes
|
||||
import org.koitharu.kotatsu.core.util.ext.MimeType
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.readByteBuffer
|
||||
import org.koitharu.kotatsu.core.util.ext.toByteBuffer
|
||||
import org.koitharu.kotatsu.core.util.ext.toMimeTypeOrNull
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
@@ -28,7 +31,7 @@ object BitmapDecoderCompat {
|
||||
|
||||
@Blocking
|
||||
fun decode(file: File): Bitmap = when (val format = probeMimeType(file)?.subtype) {
|
||||
FORMAT_AVIF -> file.inputStream().use { decodeAvif(it.toByteBuffer()) }
|
||||
FORMAT_AVIF -> file.source().buffer().use { decodeAvif(it.readByteBuffer()) }
|
||||
else -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
ImageDecoder.decodeBitmap(ImageDecoder.createSource(file))
|
||||
} else {
|
||||
|
||||
@@ -25,7 +25,7 @@ class CbzFetcher(
|
||||
val entryName = requireNotNull(uri.fragment)
|
||||
val fs = options.fileSystem.openZip(filePath)
|
||||
SourceFetchResult(
|
||||
source = ImageSource(entryName.toPath(), fs, closeable = fs),
|
||||
source = ImageSource(entryName.toPath(), fs),
|
||||
mimeType = MimeTypes.getMimeTypeFromExtension(entryName)?.toString(),
|
||||
dataSource = DataSource.DISK,
|
||||
)
|
||||
|
||||
@@ -23,6 +23,7 @@ import coil3.size.Scale
|
||||
import coil3.size.Size
|
||||
import coil3.size.isOriginal
|
||||
import coil3.size.pxOrElse
|
||||
import org.koitharu.kotatsu.core.util.ext.copyWithNewSource
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class RegionBitmapDecoder(
|
||||
@@ -34,16 +35,21 @@ class RegionBitmapDecoder(
|
||||
override suspend fun decode(): DecodeResult? {
|
||||
val regionDecoder = BitmapDecoderCompat.createRegionDecoder(fetchResult.source.source().inputStream())
|
||||
if (regionDecoder == null) {
|
||||
val fallbackDecoder = imageLoader.components.newDecoder(
|
||||
result = fetchResult,
|
||||
options = options,
|
||||
imageLoader = imageLoader,
|
||||
startIndex = 0,
|
||||
)?.first
|
||||
return if (fallbackDecoder == null || fallbackDecoder is RegionBitmapDecoder) {
|
||||
null
|
||||
} else {
|
||||
fallbackDecoder.decode()
|
||||
val revivedFetchResult = fetchResult.copyWithNewSource()
|
||||
return try {
|
||||
val fallbackDecoder = imageLoader.components.newDecoder(
|
||||
result = revivedFetchResult,
|
||||
options = options,
|
||||
imageLoader = imageLoader,
|
||||
startIndex = 0,
|
||||
)?.first
|
||||
if (fallbackDecoder == null || fallbackDecoder is RegionBitmapDecoder) {
|
||||
null
|
||||
} else {
|
||||
fallbackDecoder.decode()
|
||||
}
|
||||
} finally {
|
||||
revivedFetchResult.source.close()
|
||||
}
|
||||
}
|
||||
val bitmapOptions = BitmapFactory.Options()
|
||||
|
||||
@@ -149,6 +149,8 @@ fun Manga.chaptersCount(): Int {
|
||||
return max
|
||||
}
|
||||
|
||||
fun Manga.isNsfw(): Boolean = contentRating == ContentRating.ADULT || source.isNsfw()
|
||||
|
||||
fun MangaListFilter.getSummary() = buildSpannedString {
|
||||
if (!query.isNullOrEmpty()) {
|
||||
append(query)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.app.ActivityManager
|
||||
import android.app.ActivityManager.MemoryInfo
|
||||
@@ -53,6 +52,7 @@ import okio.use
|
||||
import org.json.JSONException
|
||||
import org.jsoup.internal.StringUtil.StringJoiner
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.main.ui.MainActivity
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
@@ -140,7 +140,6 @@ val Context.ramAvailable: Long
|
||||
return result.availMem
|
||||
}
|
||||
|
||||
@SuppressLint("DiscouragedApi")
|
||||
fun Context.getLocalesConfig(): LocaleListCompat {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
LocaleConfig(this).supportedLocales?.let {
|
||||
@@ -149,8 +148,7 @@ fun Context.getLocalesConfig(): LocaleListCompat {
|
||||
}
|
||||
val tagsList = StringJoiner(",")
|
||||
try {
|
||||
val resId = resources.getIdentifier("_generated_res_locale_config", "xml", packageName)
|
||||
val xpp: XmlPullParser = resources.getXml(resId)
|
||||
val xpp: XmlPullParser = resources.getXml(R.xml.locales_config)
|
||||
while (xpp.eventType != XmlPullParser.END_DOCUMENT) {
|
||||
if (xpp.eventType == XmlPullParser.START_TAG) {
|
||||
if (xpp.name == "locale") {
|
||||
|
||||
@@ -6,10 +6,13 @@ import android.widget.ImageView
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.annotation.CheckResult
|
||||
import coil3.Extras
|
||||
import coil3.ImageLoader
|
||||
import coil3.asDrawable
|
||||
import coil3.decode.ImageSource
|
||||
import coil3.fetch.FetchResult
|
||||
import coil3.fetch.SourceFetchResult
|
||||
import coil3.request.ErrorResult
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.ImageResult
|
||||
@@ -28,6 +31,7 @@ import coil3.toBitmap
|
||||
import coil3.util.CoilUtils
|
||||
import com.google.android.material.progressindicator.BaseProgressIndicator
|
||||
import org.koitharu.kotatsu.R
|
||||
import okio.buffer
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.core.image.RegionBitmapDecoder
|
||||
import org.koitharu.kotatsu.core.ui.image.AnimatedPlaceholderDrawable
|
||||
@@ -163,3 +167,14 @@ private class CompositeImageRequestListener(
|
||||
val mangaKey = Extras.Key<Manga?>(null)
|
||||
val bookmarkKey = Extras.Key<Bookmark?>(null)
|
||||
val mangaSourceKey = Extras.Key<MangaSource?>(null)
|
||||
|
||||
@CheckResult
|
||||
fun SourceFetchResult.copyWithNewSource(): SourceFetchResult = SourceFetchResult(
|
||||
source = ImageSource(
|
||||
source = source.fileSystem.source(source.file()).buffer(),
|
||||
fileSystem = source.fileSystem,
|
||||
metadata = source.metadata,
|
||||
),
|
||||
mimeType = mimeType,
|
||||
dataSource = dataSource,
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.ResponseBody
|
||||
import okio.BufferedSink
|
||||
import okio.BufferedSource
|
||||
import okio.FileSystem
|
||||
import okio.IOException
|
||||
import okio.Path
|
||||
@@ -30,6 +31,14 @@ suspend fun BufferedSink.writeAllCancellable(source: Source) = withContext(Dispa
|
||||
writeAll(source.cancellable())
|
||||
}
|
||||
|
||||
fun BufferedSource.readByteBuffer(): ByteBuffer {
|
||||
val bytes = readByteArray()
|
||||
return ByteBuffer.allocateDirect(bytes.size)
|
||||
.put(bytes)
|
||||
.rewind() as ByteBuffer
|
||||
}
|
||||
|
||||
@Deprecated("")
|
||||
fun InputStream.toByteBuffer(): ByteBuffer {
|
||||
val outStream = ByteArrayOutputStream(available())
|
||||
copyTo(outStream)
|
||||
|
||||
@@ -100,7 +100,11 @@ abstract class ChaptersPagesViewModel(
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false)
|
||||
|
||||
val bookmarks = mangaDetails.flatMapLatest {
|
||||
if (it != null) bookmarksRepository.observeBookmarks(it.toManga()) else flowOf(emptyList())
|
||||
if (it != null) {
|
||||
bookmarksRepository.observeBookmarks(it.toManga()).withErrorHandling()
|
||||
} else {
|
||||
flowOf(emptyList())
|
||||
}
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, emptyList())
|
||||
|
||||
val chapters = combine(
|
||||
|
||||
@@ -25,6 +25,7 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ErrorReporterReceiver
|
||||
import org.koitharu.kotatsu.core.LocalizedAppContext
|
||||
import org.koitharu.kotatsu.core.model.LocalMangaSource
|
||||
import org.koitharu.kotatsu.core.model.isNsfw
|
||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow
|
||||
import org.koitharu.kotatsu.core.util.ext.isReportable
|
||||
@@ -140,10 +141,10 @@ class DownloadNotificationFactory @AssistedInject constructor(
|
||||
builder.setSubText(null)
|
||||
builder.setShowWhen(false)
|
||||
builder.setVisibility(
|
||||
if (state != null && state.manga.isNsfw) {
|
||||
NotificationCompat.VISIBILITY_PRIVATE
|
||||
if (state != null && state.manga.isNsfw()) {
|
||||
NotificationCompat.VISIBILITY_SECRET
|
||||
} else {
|
||||
NotificationCompat.VISIBILITY_PUBLIC
|
||||
NotificationCompat.VISIBILITY_PRIVATE
|
||||
},
|
||||
)
|
||||
when {
|
||||
|
||||
@@ -181,7 +181,7 @@ abstract class BasePageHolder<B : ViewBinding>(
|
||||
}
|
||||
|
||||
is PageState.Loaded -> {
|
||||
bindingInfo.textViewStatus.setText(R.string.processing_)
|
||||
bindingInfo.textViewStatus.setText(R.string.preparing_)
|
||||
ssiv.setImage(state.source)
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ class MangaSearchRepository @Inject constructor(
|
||||
|
||||
suspend fun getMangaSuggestion(query: String, limit: Int, source: MangaSource?): List<Manga> {
|
||||
return when {
|
||||
query.isEmpty() -> db.getSuggestionDao().getRandom(limit).map { MangaWithTags(it.manga, it.tags) }
|
||||
query.isEmpty() -> db.getSuggestionDao().getRandom(limit).map { MangaWithTags(it.manga, emptyList()) }
|
||||
source != null -> db.getMangaDao().searchByTitle("%$query%", source.name, limit)
|
||||
else -> db.getMangaDao().searchByTitle("%$query%", limit)
|
||||
}.let {
|
||||
|
||||
@@ -9,11 +9,13 @@ import androidx.fragment.app.activityViewModels
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import coil3.ImageLoader
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||
import org.koitharu.kotatsu.core.os.VoiceInputContract
|
||||
import org.koitharu.kotatsu.core.ui.BaseFragment
|
||||
import org.koitharu.kotatsu.core.util.ext.addMenuProvider
|
||||
import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets
|
||||
import org.koitharu.kotatsu.core.util.ext.consumeAll
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.databinding.FragmentSearchSuggestionBinding
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.adapter.SearchSuggestionAdapter
|
||||
import javax.inject.Inject
|
||||
@@ -49,19 +51,16 @@ class SearchSuggestionFragment :
|
||||
binding.root.adapter = adapter
|
||||
binding.root.setHasFixedSize(true)
|
||||
viewModel.suggestion.observe(viewLifecycleOwner, adapter)
|
||||
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.root, this))
|
||||
ItemTouchHelper(SearchSuggestionItemCallback(this))
|
||||
.attachToRecyclerView(binding.root)
|
||||
}
|
||||
|
||||
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
|
||||
val barsInsets = insets.getInsets(WindowInsetsCompat.Type.ime() or WindowInsetsCompat.Type.systemBars())
|
||||
v.setPadding(
|
||||
barsInsets.left,
|
||||
0,
|
||||
barsInsets.right,
|
||||
barsInsets.bottom,
|
||||
)
|
||||
return insets.consumeAllSystemBarsInsets()
|
||||
val typeMask = WindowInsetsCompat.Type.ime() or WindowInsetsCompat.Type.systemBars()
|
||||
val barsInsets = insets.getInsets(typeMask)
|
||||
v.setPadding(barsInsets.left, 0, barsInsets.right, barsInsets.bottom)
|
||||
return insets.consumeAll(typeMask)
|
||||
}
|
||||
|
||||
override fun onRemoveQuery(query: String) {
|
||||
|
||||
@@ -21,11 +21,12 @@ import org.koitharu.kotatsu.core.prefs.observeAsFlow
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import org.koitharu.kotatsu.parsers.util.sizeOrZero
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem
|
||||
import javax.inject.Inject
|
||||
@@ -87,7 +88,7 @@ class SearchSuggestionViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun onResume() {
|
||||
if (invalidateOnResume) {
|
||||
if (invalidateOnResume || suggestionJob?.isActive != true) {
|
||||
invalidateOnResume = false
|
||||
setupSuggestion()
|
||||
}
|
||||
@@ -120,62 +121,114 @@ class SearchSuggestionViewModel @Inject constructor(
|
||||
enabledSources: Set<String>,
|
||||
types: Set<SearchSuggestionType>,
|
||||
): List<SearchSuggestionItem> = coroutineScope {
|
||||
val queriesDeferred = if (SearchSuggestionType.QUERIES_RECENT in types) {
|
||||
async { repository.getQuerySuggestion(searchQuery, MAX_QUERY_ITEMS) }
|
||||
listOfNotNull(
|
||||
if (SearchSuggestionType.GENRES in types) {
|
||||
async { getTags(searchQuery) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
if (SearchSuggestionType.MANGA in types) {
|
||||
async { getManga(searchQuery) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
if (SearchSuggestionType.QUERIES_RECENT in types) {
|
||||
async { getRecentQueries(searchQuery) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
if (SearchSuggestionType.QUERIES_SUGGEST in types) {
|
||||
async { getQueryHints(searchQuery) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
if (SearchSuggestionType.SOURCES in types) {
|
||||
async { getSources(searchQuery, enabledSources) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
if (SearchSuggestionType.RECENT_SOURCES in types) {
|
||||
async { getRecentSources(searchQuery) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
if (SearchSuggestionType.AUTHORS in types) {
|
||||
async {
|
||||
getAuthors(searchQuery)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
).flatMap { it.await() }
|
||||
}
|
||||
|
||||
private suspend fun getAuthors(searchQuery: String): List<SearchSuggestionItem> = runCatchingCancellable {
|
||||
repository.getAuthorsSuggestion(searchQuery, MAX_AUTHORS_ITEMS)
|
||||
.map { SearchSuggestionItem.Author(it) }
|
||||
}.getOrElse { e ->
|
||||
e.printStackTraceDebug()
|
||||
listOf(SearchSuggestionItem.Text(0, e))
|
||||
}
|
||||
|
||||
private suspend fun getQueryHints(searchQuery: String): List<SearchSuggestionItem> = runCatchingCancellable {
|
||||
repository.getQueryHintSuggestion(searchQuery, MAX_HINTS_ITEMS)
|
||||
.map { SearchSuggestionItem.Hint(it) }
|
||||
}.getOrElse { e ->
|
||||
e.printStackTraceDebug()
|
||||
listOf(SearchSuggestionItem.Text(0, e))
|
||||
}
|
||||
|
||||
private suspend fun getRecentQueries(searchQuery: String): List<SearchSuggestionItem> = runCatchingCancellable {
|
||||
repository.getQuerySuggestion(searchQuery, MAX_QUERY_ITEMS)
|
||||
.map { SearchSuggestionItem.RecentQuery(it) }
|
||||
}.getOrElse { e ->
|
||||
e.printStackTraceDebug()
|
||||
listOf(SearchSuggestionItem.Text(0, e))
|
||||
}
|
||||
|
||||
private suspend fun getTags(searchQuery: String): List<SearchSuggestionItem> = runCatchingCancellable {
|
||||
val tags = repository.getTagsSuggestion(searchQuery, MAX_TAGS_ITEMS, null)
|
||||
if (tags.isEmpty()) {
|
||||
emptyList()
|
||||
} else {
|
||||
null
|
||||
listOf(SearchSuggestionItem.Tags(mapTags(tags)))
|
||||
}
|
||||
val hintsDeferred = if (SearchSuggestionType.QUERIES_SUGGEST in types) {
|
||||
async { repository.getQueryHintSuggestion(searchQuery, MAX_HINTS_ITEMS) }
|
||||
}.getOrElse { e ->
|
||||
e.printStackTraceDebug()
|
||||
listOf(SearchSuggestionItem.Text(0, e))
|
||||
}
|
||||
|
||||
private suspend fun getManga(searchQuery: String): List<SearchSuggestionItem> = runCatchingCancellable {
|
||||
val manga = repository.getMangaSuggestion(searchQuery, MAX_MANGA_ITEMS, null)
|
||||
if (manga.isEmpty()) {
|
||||
emptyList()
|
||||
} else {
|
||||
null
|
||||
listOf(SearchSuggestionItem.MangaList(manga))
|
||||
}
|
||||
val authorsDeferred = if (SearchSuggestionType.AUTHORS in types) {
|
||||
async { repository.getAuthorsSuggestion(searchQuery, MAX_AUTHORS_ITEMS) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val tagsDeferred = if (SearchSuggestionType.GENRES in types) {
|
||||
async { repository.getTagsSuggestion(searchQuery, MAX_TAGS_ITEMS, null) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val mangaDeferred = if (SearchSuggestionType.MANGA in types) {
|
||||
async { repository.getMangaSuggestion(searchQuery, MAX_MANGA_ITEMS, null) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val sources = if (SearchSuggestionType.SOURCES in types) {
|
||||
}.getOrElse { e ->
|
||||
e.printStackTraceDebug()
|
||||
listOf(SearchSuggestionItem.Text(0, e))
|
||||
}
|
||||
|
||||
private suspend fun getSources(searchQuery: String, enabledSources: Set<String>): List<SearchSuggestionItem> =
|
||||
runCatchingCancellable {
|
||||
repository.getSourcesSuggestion(searchQuery, MAX_SOURCES_ITEMS)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val sourcesTipsDeferred = if (searchQuery.isEmpty() && SearchSuggestionType.RECENT_SOURCES in types) {
|
||||
async { repository.getSourcesSuggestion(MAX_SOURCES_TIPS_ITEMS) }
|
||||
} else {
|
||||
null
|
||||
.map { SearchSuggestionItem.Source(it, it.name in enabledSources) }
|
||||
}.getOrElse { e ->
|
||||
e.printStackTraceDebug()
|
||||
listOf(SearchSuggestionItem.Text(0, e))
|
||||
}
|
||||
|
||||
val tags = tagsDeferred?.await()
|
||||
val mangaList = mangaDeferred?.await()
|
||||
val queries = queriesDeferred?.await()
|
||||
val hints = hintsDeferred?.await()
|
||||
val authors = authorsDeferred?.await()
|
||||
val sourcesTips = sourcesTipsDeferred?.await()
|
||||
|
||||
buildList(queries.sizeOrZero() + sources.sizeOrZero() + authors.sizeOrZero() + hints.sizeOrZero() + 2) {
|
||||
if (!tags.isNullOrEmpty()) {
|
||||
add(SearchSuggestionItem.Tags(mapTags(tags)))
|
||||
}
|
||||
if (!mangaList.isNullOrEmpty()) {
|
||||
add(SearchSuggestionItem.MangaList(mangaList))
|
||||
}
|
||||
sources?.mapTo(this) { SearchSuggestionItem.Source(it, it.name in enabledSources) }
|
||||
queries?.mapTo(this) { SearchSuggestionItem.RecentQuery(it) }
|
||||
authors?.mapTo(this) { SearchSuggestionItem.Author(it) }
|
||||
hints?.mapTo(this) { SearchSuggestionItem.Hint(it) }
|
||||
sourcesTips?.mapTo(this) { SearchSuggestionItem.SourceTip(it) }
|
||||
private suspend fun getRecentSources(searchQuery: String): List<SearchSuggestionItem> = if (searchQuery.isEmpty()) {
|
||||
runCatchingCancellable {
|
||||
repository.getSourcesSuggestion(MAX_SOURCES_TIPS_ITEMS)
|
||||
.map { SearchSuggestionItem.SourceTip(it) }
|
||||
}.getOrElse { e ->
|
||||
e.printStackTraceDebug()
|
||||
listOf(SearchSuggestionItem.Text(0, e))
|
||||
}
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
private fun mapTags(tags: List<MangaTag>): List<ChipsView.ChipModel> = tags.map { tag ->
|
||||
|
||||
@@ -23,5 +23,6 @@ class SearchSuggestionAdapter(
|
||||
.addDelegate(searchSuggestionMangaListAD(coil, lifecycleOwner, listener))
|
||||
.addDelegate(searchSuggestionQueryHintAD(listener))
|
||||
.addDelegate(searchSuggestionAuthorAD(listener))
|
||||
.addDelegate(searchSuggestionTextAD())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package org.koitharu.kotatsu.search.ui.suggestion.adapter
|
||||
|
||||
import android.widget.TextView
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem
|
||||
|
||||
fun searchSuggestionTextAD() = adapterDelegate<SearchSuggestionItem.Text, SearchSuggestionItem>(
|
||||
R.layout.item_search_suggestion_text,
|
||||
) {
|
||||
|
||||
bind {
|
||||
val tv = itemView as TextView
|
||||
val isError = item.error != null
|
||||
tv.setCompoundDrawablesRelativeWithIntrinsicBounds(
|
||||
if (isError) R.drawable.ic_error_small else 0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
if (item.textResId != 0) {
|
||||
tv.setText(item.textResId)
|
||||
} else {
|
||||
tv.text = item.error?.getDisplayMessage(tv.resources)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.search.ui.suggestion.model
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import org.koitharu.kotatsu.core.model.isNsfw
|
||||
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
||||
@@ -93,4 +94,15 @@ sealed interface SearchSuggestionItem : ListModel {
|
||||
return ListModelDiffCallback.PAYLOAD_NESTED_LIST_CHANGED
|
||||
}
|
||||
}
|
||||
|
||||
data class Text(
|
||||
@StringRes val textResId: Int,
|
||||
val error: Throwable?,
|
||||
) : SearchSuggestionItem {
|
||||
|
||||
override fun areItemsTheSame(other: ListModel): Boolean = other is Text
|
||||
&& textResId == other.textResId
|
||||
&& error?.javaClass == other.error?.javaClass
|
||||
&& error?.message == other.error?.message
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.room.Update
|
||||
import androidx.sqlite.db.SupportSQLiteQuery
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.koitharu.kotatsu.core.db.MangaQueryBuilder
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
|
||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
||||
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
||||
|
||||
@@ -33,12 +34,10 @@ abstract class SuggestionDao : MangaQueryBuilder.ConditionCallback {
|
||||
)
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM suggestions ORDER BY RANDOM() LIMIT 1")
|
||||
abstract suspend fun getRandom(): SuggestionWithManga?
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM suggestions ORDER BY RANDOM() LIMIT :limit")
|
||||
abstract suspend fun getRandom(limit: Int): List<SuggestionWithManga>
|
||||
open suspend fun getRandom(limit: Int): List<MangaWithTags> {
|
||||
val ids = getRandomIds(limit)
|
||||
return getByIds(ids)
|
||||
}
|
||||
|
||||
@Query("SELECT COUNT(*) FROM suggestions")
|
||||
abstract suspend fun count(): Int
|
||||
@@ -68,6 +67,12 @@ abstract class SuggestionDao : MangaQueryBuilder.ConditionCallback {
|
||||
}
|
||||
}
|
||||
|
||||
@Query("SELECT * FROM manga WHERE manga_id IN (:ids)")
|
||||
protected abstract suspend fun getByIds(ids: LongArray): List<MangaWithTags>
|
||||
|
||||
@Query("SELECT manga_id FROM suggestions ORDER BY RANDOM() LIMIT :limit")
|
||||
protected abstract suspend fun getRandomIds(limit: Int): LongArray
|
||||
|
||||
@Transaction
|
||||
@RawQuery(observedEntities = [SuggestionEntity::class])
|
||||
protected abstract fun observeAllImpl(query: SupportSQLiteQuery): Flow<List<SuggestionWithManga>>
|
||||
@@ -75,7 +80,12 @@ abstract class SuggestionDao : MangaQueryBuilder.ConditionCallback {
|
||||
override fun getCondition(option: ListFilterOption): String? = when (option) {
|
||||
ListFilterOption.Macro.NSFW -> "(SELECT nsfw FROM manga WHERE manga.manga_id = suggestions.manga_id) = 1"
|
||||
is ListFilterOption.Tag -> "EXISTS(SELECT * FROM manga_tags WHERE manga_tags.manga_id = suggestions.manga_id AND tag_id = ${option.tagId})"
|
||||
is ListFilterOption.Source -> "(SELECT source FROM manga WHERE manga.manga_id = suggestions.manga_id) = ${sqlEscapeString(option.mangaSource.name)}"
|
||||
is ListFilterOption.Source -> "(SELECT source FROM manga WHERE manga.manga_id = suggestions.manga_id) = ${
|
||||
sqlEscapeString(
|
||||
option.mangaSource.name,
|
||||
)
|
||||
}"
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.db.entity.toEntities
|
||||
import org.koitharu.kotatsu.core.db.entity.toEntity
|
||||
import org.koitharu.kotatsu.core.db.entity.toManga
|
||||
import org.koitharu.kotatsu.core.db.entity.toMangaTags
|
||||
import org.koitharu.kotatsu.core.db.entity.toMangaTagsList
|
||||
import org.koitharu.kotatsu.core.model.toMangaSources
|
||||
import org.koitharu.kotatsu.core.util.ext.mapItems
|
||||
@@ -34,10 +33,6 @@ class SuggestionRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getRandom(): Manga? {
|
||||
return db.getSuggestionDao().getRandom()?.toManga()
|
||||
}
|
||||
|
||||
suspend fun getRandomList(limit: Int): List<Manga> {
|
||||
return db.getSuggestionDao().getRandom(limit).map {
|
||||
it.toManga()
|
||||
@@ -80,5 +75,5 @@ class SuggestionRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun SuggestionWithManga.toManga() = manga.toManga(tags.toMangaTags(), null)
|
||||
private fun SuggestionWithManga.toManga() = manga.toManga(emptySet(), null)
|
||||
}
|
||||
|
||||
@@ -352,7 +352,7 @@ class SuggestionsWorker @AssistedInject constructor(
|
||||
)
|
||||
setAutoCancel(true)
|
||||
setCategory(NotificationCompat.CATEGORY_RECOMMENDATION)
|
||||
setVisibility(if (manga.isNsfw) NotificationCompat.VISIBILITY_SECRET else NotificationCompat.VISIBILITY_PUBLIC)
|
||||
setVisibility(if (manga.isNsfw()) NotificationCompat.VISIBILITY_SECRET else NotificationCompat.VISIBILITY_PRIVATE)
|
||||
setShortcutId(manga.id.toString())
|
||||
priority = NotificationCompat.PRIORITY_DEFAULT
|
||||
|
||||
|
||||
@@ -27,17 +27,17 @@ abstract class TracksDao : MangaQueryBuilder.ConditionCallback {
|
||||
@Query("SELECT * FROM tracks WHERE manga_id = :mangaId")
|
||||
abstract suspend fun find(mangaId: Long): TrackEntity?
|
||||
|
||||
@Query("SELECT chapters_new FROM tracks WHERE manga_id = :mangaId")
|
||||
abstract suspend fun findNewChapters(mangaId: Long): Int?
|
||||
@Query("SELECT IFNULL(chapters_new,0) FROM tracks WHERE manga_id = :mangaId")
|
||||
abstract suspend fun findNewChapters(mangaId: Long): Int
|
||||
|
||||
@Query("SELECT COUNT(*) FROM tracks")
|
||||
abstract suspend fun getTracksCount(): Int
|
||||
|
||||
@Query("SELECT chapters_new FROM tracks")
|
||||
abstract fun observeNewChapters(): Flow<List<Int>>
|
||||
@Query("SELECT COUNT(*) FROM tracks WHERE chapters_new > 0")
|
||||
abstract fun observeUpdateMangaCount(): Flow<Int>
|
||||
|
||||
@Query("SELECT chapters_new FROM tracks WHERE manga_id = :mangaId")
|
||||
abstract fun observeNewChapters(mangaId: Long): Flow<Int?>
|
||||
@Query("SELECT IFNULL(chapters_new, 0) FROM tracks WHERE manga_id = :mangaId")
|
||||
abstract fun observeNewChapters(mangaId: Long): Flow<Int>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM tracks WHERE chapters_new > 0 ORDER BY last_chapter_date DESC")
|
||||
|
||||
@@ -5,7 +5,6 @@ import androidx.room.withTransaction
|
||||
import dagger.Reusable
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.db.entity.toManga
|
||||
@@ -39,16 +38,16 @@ class TrackingRepository @Inject constructor(
|
||||
private var isGcCalled = AtomicBoolean(false)
|
||||
|
||||
suspend fun getNewChaptersCount(mangaId: Long): Int {
|
||||
return db.getTracksDao().findNewChapters(mangaId) ?: 0
|
||||
return db.getTracksDao().findNewChapters(mangaId)
|
||||
}
|
||||
|
||||
fun observeNewChaptersCount(mangaId: Long): Flow<Int> {
|
||||
return db.getTracksDao().observeNewChapters(mangaId).map { it ?: 0 }
|
||||
return db.getTracksDao().observeNewChapters(mangaId)
|
||||
}
|
||||
|
||||
@Deprecated("")
|
||||
fun observeUpdatedMangaCount(): Flow<Int> {
|
||||
return db.getTracksDao().observeNewChapters().map { list -> list.count { it > 0 } }
|
||||
return db.getTracksDao().observeUpdateMangaCount()
|
||||
.onStart { gcIfNotCalled() }
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ class TrackerDebugViewModel @Inject constructor(
|
||||
|
||||
val content = db.getTracksDao().observeAll()
|
||||
.map { it.toUiList() }
|
||||
.withErrorHandling()
|
||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
private fun List<TrackWithManga>.toUiList(): List<TrackDebugItem> = map {
|
||||
|
||||
@@ -7,7 +7,7 @@ import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationCompat.VISIBILITY_PUBLIC
|
||||
import androidx.core.app.NotificationCompat.VISIBILITY_PRIVATE
|
||||
import androidx.core.app.NotificationCompat.VISIBILITY_SECRET
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
@@ -17,12 +17,14 @@ import coil3.request.ImageRequest
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.LocalizedAppContext
|
||||
import org.koitharu.kotatsu.core.model.getLocalizedTitle
|
||||
import org.koitharu.kotatsu.core.model.isNsfw
|
||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
||||
import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe
|
||||
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
|
||||
import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull
|
||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import javax.inject.Inject
|
||||
@@ -51,7 +53,7 @@ class TrackerNotificationHelper @Inject constructor(
|
||||
if (newChapters.isEmpty() || !applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
||||
return null
|
||||
}
|
||||
if (manga.isNsfw && (settings.isTrackerNsfwDisabled || settings.isNsfwContentDisabled)) {
|
||||
if (manga.isNsfw() && (settings.isTrackerNsfwDisabled || settings.isNsfwContentDisabled)) {
|
||||
return null
|
||||
}
|
||||
val id = manga.url.hashCode()
|
||||
@@ -92,7 +94,7 @@ class TrackerNotificationHelper @Inject constructor(
|
||||
false,
|
||||
),
|
||||
)
|
||||
setVisibility(if (manga.isNsfw) VISIBILITY_SECRET else VISIBILITY_PUBLIC)
|
||||
setVisibility(if (manga.isNsfw()) VISIBILITY_SECRET else VISIBILITY_PRIVATE)
|
||||
setShortcutId(manga.id.toString())
|
||||
applyCommonSettings(this)
|
||||
}
|
||||
@@ -127,6 +129,13 @@ class TrackerNotificationHelper @Inject constructor(
|
||||
setNumber(newChaptersCount)
|
||||
setGroup(GROUP_NEW_CHAPTERS)
|
||||
setGroupSummary(true)
|
||||
setVisibility(
|
||||
if (notifications.any { it.manga.isNsfw() }) {
|
||||
VISIBILITY_SECRET
|
||||
} else {
|
||||
VISIBILITY_PRIVATE
|
||||
},
|
||||
)
|
||||
val intent = AppRouter.mangaUpdatesIntent(applicationContext)
|
||||
setContentIntent(
|
||||
PendingIntentCompat.getActivity(
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:alpha="0.4" android:color="?android:colorBackground" />
|
||||
</selector>
|
||||
4
app/src/main/res/color/bg_background_transparency.xml
Normal file
4
app/src/main/res/color/bg_background_transparency.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:alpha="0.4" android:color="@color/kotatsu_background" />
|
||||
</selector>
|
||||
10
app/src/main/res/drawable/bg_rounded_transparency.xml
Normal file
10
app/src/main/res/drawable/bg_rounded_transparency.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
|
||||
<corners android:radius="4dp" />
|
||||
|
||||
<solid android:color="@color/bg_background_transparency" />
|
||||
|
||||
</shape>
|
||||
14
app/src/main/res/layout/item_search_suggestion_text.xml
Normal file
14
app/src/main/res/layout/item_search_suggestion_text.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TextView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:drawablePadding="@dimen/screen_padding"
|
||||
android:paddingVertical="@dimen/margin_small"
|
||||
android:paddingStart="?listPreferredItemPaddingStart"
|
||||
android:paddingEnd="?listPreferredItemPaddingEnd"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
android:textColor="?android:textColorSecondary"
|
||||
tools:drawableStart="@drawable/ic_error_small"
|
||||
tools:text="@string/error_corrupted_file" />
|
||||
@@ -12,7 +12,6 @@
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginHorizontal="@dimen/screen_padding"
|
||||
android:layout_marginBottom="-12dp"
|
||||
app:cardBackgroundColor="?colorBackgroundFloating"
|
||||
app:layout_constraintBottom_toBottomOf="@id/textView_progress_label"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
@@ -230,7 +229,7 @@
|
||||
|
||||
<com.google.android.material.progressindicator.LinearProgressIndicator
|
||||
android:id="@+id/progress"
|
||||
style="@style/Widget.Material3.LinearProgressIndicator"
|
||||
style="?linearProgressIndicatorStyle"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="@dimen/margin_normal"
|
||||
@@ -242,6 +241,7 @@
|
||||
app:layout_constraintEnd_toStartOf="@id/textView_progress"
|
||||
app:layout_constraintStart_toEndOf="@id/barrier_table"
|
||||
app:layout_constraintTop_toTopOf="@id/textView_progress_label"
|
||||
app:trackColor="?android:colorBackground"
|
||||
tools:progress="12" />
|
||||
|
||||
<TextView
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
android:id="@+id/progressBar"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="@dimen/badge_offset"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:indeterminate="true"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible" />
|
||||
@@ -29,7 +29,9 @@
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:background="@drawable/bg_rounded_transparency"
|
||||
android:gravity="center"
|
||||
android:padding="4dp"
|
||||
android:textAppearance="?textAppearanceBodyLarge"
|
||||
tools:text="72%" />
|
||||
</LinearLayout>
|
||||
|
||||
@@ -1,22 +1,107 @@
|
||||
yaoi
|
||||
yuri
|
||||
amputation
|
||||
amputee
|
||||
anal birth
|
||||
anal torture
|
||||
bdsm
|
||||
beast
|
||||
beastiality
|
||||
bestiality
|
||||
birth
|
||||
blackmail
|
||||
blood
|
||||
body horror
|
||||
bondage
|
||||
boys' love
|
||||
brother
|
||||
bukkake
|
||||
cannibalism
|
||||
cbt
|
||||
choking
|
||||
coprophagia
|
||||
degradation
|
||||
diapers
|
||||
drugs
|
||||
egg laying
|
||||
electrical play
|
||||
electro
|
||||
electro play
|
||||
enema
|
||||
extreme
|
||||
father
|
||||
femdom
|
||||
force
|
||||
full censorship
|
||||
furry
|
||||
futanari
|
||||
gang rape
|
||||
gangbang
|
||||
gangbang rape
|
||||
gender bender
|
||||
girls' love
|
||||
guro
|
||||
human pet
|
||||
humiliation
|
||||
hypno
|
||||
incest
|
||||
inflation
|
||||
insect
|
||||
inseki
|
||||
knife play
|
||||
loli
|
||||
lolicon
|
||||
machine
|
||||
mind break
|
||||
mindbreak
|
||||
molestation
|
||||
mosaic
|
||||
mother
|
||||
mutilation
|
||||
necrophila
|
||||
necrophilia
|
||||
netorase
|
||||
nipple torture
|
||||
non-consensual
|
||||
ntr
|
||||
orgasm denial
|
||||
parasite
|
||||
piercing
|
||||
prolapse
|
||||
prostitution
|
||||
public use
|
||||
puke
|
||||
puppy play
|
||||
rape
|
||||
ryona
|
||||
scar
|
||||
scat
|
||||
shemale
|
||||
shota
|
||||
shotacon
|
||||
sister
|
||||
slave
|
||||
slavery
|
||||
snuff
|
||||
tentacles
|
||||
toddlercon
|
||||
torture
|
||||
trans
|
||||
transgender
|
||||
trap
|
||||
traps
|
||||
guro
|
||||
furry
|
||||
loli
|
||||
incest
|
||||
tentacles
|
||||
shemale
|
||||
scat
|
||||
яой
|
||||
юри
|
||||
трап
|
||||
копро
|
||||
unbirth
|
||||
urination
|
||||
vaginal birth
|
||||
violent
|
||||
vomit
|
||||
vore
|
||||
watersports
|
||||
yaoi
|
||||
yuri
|
||||
гуро
|
||||
тентакли
|
||||
футанари
|
||||
инцест
|
||||
boys' love
|
||||
girls' love
|
||||
bdsm
|
||||
копро
|
||||
тентакли
|
||||
трап
|
||||
футанари
|
||||
юри
|
||||
яой
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
unqualifiedResLocale=en-US
|
||||
62
app/src/main/res/xml/locales_config.xml
Normal file
62
app/src/main/res/xml/locales_config.xml
Normal file
@@ -0,0 +1,62 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<locale-config
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<locale android:name="ab" />
|
||||
<locale android:name="ar" />
|
||||
<locale android:name="arq" />
|
||||
<locale android:name="as" />
|
||||
<locale android:name="be" />
|
||||
<locale android:name="bn" />
|
||||
<locale android:name="ca" />
|
||||
<locale android:name="cs" />
|
||||
<locale android:name="de" />
|
||||
<locale android:name="el" />
|
||||
<locale android:name="en-GB" />
|
||||
<locale android:name="enm" />
|
||||
<locale android:name="es" />
|
||||
<locale android:name="et" />
|
||||
<locale android:name="eu" />
|
||||
<locale android:name="fa" />
|
||||
<locale android:name="fi" />
|
||||
<locale android:name="fil" />
|
||||
<locale android:name="fr" />
|
||||
<locale android:name="frp" />
|
||||
<locale android:name="gu" />
|
||||
<locale android:name="he" />
|
||||
<locale android:name="hi" />
|
||||
<locale android:name="hr" />
|
||||
<locale android:name="hu" />
|
||||
<locale android:name="id" />
|
||||
<locale android:name="it" />
|
||||
<locale android:name="ja" />
|
||||
<locale android:name="kk" />
|
||||
<locale android:name="km" />
|
||||
<locale android:name="ko" />
|
||||
<locale android:name="lt" />
|
||||
<locale android:name="lv" />
|
||||
<locale android:name="lzh" />
|
||||
<locale android:name="ml" />
|
||||
<locale android:name="ms" />
|
||||
<locale android:name="my" />
|
||||
<locale android:name="nb-NO" />
|
||||
<locale android:name="ne" />
|
||||
<locale android:name="nn" />
|
||||
<locale android:name="or" />
|
||||
<locale android:name="pa" />
|
||||
<locale android:name="pa-PK" />
|
||||
<locale android:name="pl" />
|
||||
<locale android:name="pt" />
|
||||
<locale android:name="pt-BR" />
|
||||
<locale android:name="ro" />
|
||||
<locale android:name="ru" />
|
||||
<locale android:name="si" />
|
||||
<locale android:name="sr" />
|
||||
<locale android:name="sv" />
|
||||
<locale android:name="ta" />
|
||||
<locale android:name="th" />
|
||||
<locale android:name="tr" />
|
||||
<locale android:name="uk" />
|
||||
<locale android:name="vi" />
|
||||
<locale android:name="zh-CN" />
|
||||
<locale android:name="zh-TW" />
|
||||
</locale-config>
|
||||
@@ -31,7 +31,7 @@ material = "1.13.0-alpha12"
|
||||
moshi = "1.15.2"
|
||||
okhttp = "4.12.0"
|
||||
okio = "3.11.0"
|
||||
parsers = "e874837efb"
|
||||
parsers = "cf0177364c"
|
||||
preference = "1.2.1"
|
||||
recyclerview = "1.4.0"
|
||||
room = "2.6.1"
|
||||
|
||||
Reference in New Issue
Block a user