Compare commits

...

14 Commits

Author SHA1 Message Date
Koitharu
beba4f029a Fix per-app locale selection 2025-05-08 20:46:58 +03:00
Koitharu
7cf7a62881 Update details info card color
(cherry picked from commit 8d325aea0a)
2025-05-08 19:36:16 +03:00
kadirkid
c1e84715fb Switch per language support to manual
The current automatic support setup has a bug where the app language will change for users with Android 15 when there is a configuration change like rotating a screen. It seems that that using generateLocaleConfig on AGP 8.8+ triggers a bug in Android 15 (android:defaultLocale) which causes this issue

(cherry picked from commit 104d8da655)
2025-05-08 19:36:08 +03:00
Koitharu
a3cc5726ee Update parsers 2025-05-08 19:35:45 +03:00
Koitharu
3023c02f12 Update parsers 2025-05-03 08:37:46 +03:00
Koitharu
efff034dc6 Remove duplicated warnlist tags 2025-05-03 08:33:36 +03:00
Draken
2bb5673446 Update tags_warnlist
(cherry picked from commit 8d78b19128)
2025-05-03 08:31:39 +03:00
Koitharu
0983885fa2 Update private notifications visibility 2025-05-03 08:30:36 +03:00
Koitharu
4449996a91 Fix search suggestions
(cherry picked from commit 1a8045b89f)
2025-05-02 14:44:39 +03:00
Koitharu
9cf496b7c4 AVIF images downsampling
(cherry picked from commit 5d890cb3d0)
2025-05-02 14:44:25 +03:00
Koitharu
4fb1db47ab Fix image loading
(cherry picked from commit 257f583f78)
2025-05-02 14:44:12 +03:00
Koitharu
14b89fbee2 Use pagination for bookmarks backup 2025-04-19 08:31:47 +03:00
Koitharu
8291c55fc9 Fix some database-related crashes 2025-04-19 08:16:08 +03:00
Koitharu
46ddcb7518 Update page loading ui 2025-04-13 11:33:04 +03:00
38 changed files with 598 additions and 172 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
)

View File

@@ -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()

View File

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

View File

@@ -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") {

View File

@@ -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,
)

View File

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

View File

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

View File

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

View File

@@ -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)
}

View File

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

View File

@@ -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) {

View File

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

View File

@@ -23,5 +23,6 @@ class SearchSuggestionAdapter(
.addDelegate(searchSuggestionMangaListAD(coil, lifecycleOwner, listener))
.addDelegate(searchSuggestionQueryHintAD(listener))
.addDelegate(searchSuggestionAuthorAD(listener))
.addDelegate(searchSuggestionTextAD())
}
}

View File

@@ -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)
}
}
}

View File

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

View File

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

View File

@@ -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)
}

View File

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

View File

@@ -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")

View File

@@ -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() }
}

View File

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

View File

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

View 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="?android:colorBackground" />
</selector>

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

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

View 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" />

View 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

View File

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

View File

@@ -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
копро
тентакли
трап
футанари
юри
яой

View File

@@ -1 +0,0 @@
unqualifiedResLocale=en-US

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

View File

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