Merge branch 'devel' of github.com:nv95/Kotatsu into feature/suggestions
This commit is contained in:
@@ -5,29 +5,29 @@ import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import org.koitharu.kotatsu.core.model.Manga
|
||||
|
||||
class MangaIntent(
|
||||
class MangaIntent private constructor(
|
||||
val manga: Manga?,
|
||||
val mangaId: Long,
|
||||
val uri: Uri?
|
||||
val uri: Uri?,
|
||||
) {
|
||||
|
||||
constructor(intent: Intent?) : this(
|
||||
manga = intent?.getParcelableExtra(KEY_MANGA),
|
||||
mangaId = intent?.getLongExtra(KEY_ID, ID_NONE) ?: ID_NONE,
|
||||
uri = intent?.data
|
||||
)
|
||||
|
||||
constructor(args: Bundle?) : this(
|
||||
manga = args?.getParcelable(KEY_MANGA),
|
||||
mangaId = args?.getLong(KEY_ID, ID_NONE) ?: ID_NONE,
|
||||
uri = null
|
||||
)
|
||||
|
||||
companion object {
|
||||
|
||||
fun from(intent: Intent?) = MangaIntent(
|
||||
manga = intent?.getParcelableExtra(KEY_MANGA),
|
||||
mangaId = intent?.getLongExtra(KEY_ID, ID_NONE) ?: ID_NONE,
|
||||
uri = intent?.data
|
||||
)
|
||||
|
||||
fun from(args: Bundle?) = MangaIntent(
|
||||
manga = args?.getParcelable(KEY_MANGA),
|
||||
mangaId = args?.getLong(KEY_ID, ID_NONE) ?: ID_NONE,
|
||||
uri = null
|
||||
)
|
||||
|
||||
const val ID_NONE = 0L
|
||||
|
||||
const val KEY_MANGA = "manga"
|
||||
const val KEY_ID = "id"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
package org.koitharu.kotatsu.base.domain
|
||||
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
|
||||
object MangaProviderFactory {
|
||||
|
||||
fun getSources(settings: AppSettings, includeHidden: Boolean): List<MangaSource> {
|
||||
val list = MangaSource.values().toList() - MangaSource.LOCAL
|
||||
val order = settings.sourcesOrder
|
||||
val sorted = list.sortedBy { x ->
|
||||
val e = order.indexOf(x.ordinal)
|
||||
if (e == -1) order.size + x.ordinal else e
|
||||
}
|
||||
return if (includeHidden) {
|
||||
sorted
|
||||
} else {
|
||||
val hidden = settings.hiddenSources
|
||||
sorted.filterNot { x ->
|
||||
x.name in hidden
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import android.net.Uri
|
||||
import android.util.Size
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.koin.core.component.KoinComponent
|
||||
@@ -14,7 +13,6 @@ import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.model.MangaPage
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.utils.CacheUtils
|
||||
import org.koitharu.kotatsu.utils.ext.await
|
||||
import org.koitharu.kotatsu.utils.ext.medianOrNull
|
||||
import java.io.InputStream
|
||||
@@ -40,15 +38,14 @@ object MangaUtils : KoinComponent {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val client = get<OkHttpClient>()
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.header(CommonHeaders.REFERER, page.referer)
|
||||
.cacheControl(CacheUtils.CONTROL_DISABLED)
|
||||
.cacheControl(CommonHeaders.CACHE_CONTROL_DISABLED)
|
||||
.build()
|
||||
client.newCall(request).await().use {
|
||||
withContext(Dispatchers.IO) {
|
||||
get<OkHttpClient>().newCall(request).await().use {
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
getBitmapSize(it.body?.byteStream())
|
||||
}
|
||||
}
|
||||
@@ -66,10 +63,10 @@ object MangaUtils : KoinComponent {
|
||||
val options = BitmapFactory.Options().apply {
|
||||
inJustDecodeBounds = true
|
||||
}
|
||||
BitmapFactory.decodeStream(input, null, options)
|
||||
BitmapFactory.decodeStream(input, null, options)?.recycle()
|
||||
val imageHeight: Int = options.outHeight
|
||||
val imageWidth: Int = options.outWidth
|
||||
check(imageHeight > 0 && imageWidth > 0)
|
||||
return Size(imageWidth, imageHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,7 @@ abstract class AlertDialogFragment<B : ViewBinding> : DialogFragment() {
|
||||
get() = checkNotNull(viewBinding)
|
||||
|
||||
final override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val inflater = activity?.layoutInflater ?: LayoutInflater.from(requireContext())
|
||||
val binding = onInflateView(inflater, null)
|
||||
val binding = onInflateView(layoutInflater, null)
|
||||
viewBinding = binding
|
||||
return MaterialAlertDialogBuilder(requireContext(), theme)
|
||||
.setView(binding.root)
|
||||
@@ -43,4 +42,4 @@ abstract class AlertDialogFragment<B : ViewBinding> : DialogFragment() {
|
||||
protected fun bindingOrNull(): B? = viewBinding
|
||||
|
||||
protected abstract fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): B
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,9 +35,10 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(), OnApplyWindo
|
||||
private var lastInsets: Insets = Insets.NONE
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
val settings = get<AppSettings>()
|
||||
when {
|
||||
get<AppSettings>().isAmoledTheme -> setTheme(R.style.ThemeOverlay_Kotatsu_AMOLED)
|
||||
get<AppSettings>().isDynamicTheme -> setTheme(R.style.Theme_Kotatsu_Monet)
|
||||
settings.isAmoledTheme -> setTheme(R.style.ThemeOverlay_Kotatsu_AMOLED)
|
||||
settings.isDynamicTheme -> setTheme(R.style.Theme_Kotatsu_Monet)
|
||||
}
|
||||
super.onCreate(savedInstanceState)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
@@ -130,4 +131,4 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(), OnApplyWindo
|
||||
super.onBackPressed()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,7 @@ import androidx.viewbinding.ViewBinding
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
import org.koitharu.kotatsu.R
|
||||
|
||||
abstract class BaseBottomSheet<B : ViewBinding> :
|
||||
BottomSheetDialogFragment() {
|
||||
abstract class BaseBottomSheet<B : ViewBinding> : BottomSheetDialogFragment() {
|
||||
|
||||
private var viewBinding: B? = null
|
||||
|
||||
@@ -40,4 +39,4 @@ abstract class BaseBottomSheet<B : ViewBinding> :
|
||||
}
|
||||
|
||||
protected abstract fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): B
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ import androidx.preference.PreferenceFragmentCompat
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
|
||||
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
|
||||
PreferenceFragmentCompat(), OnApplyWindowInsetsListener {
|
||||
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) : PreferenceFragmentCompat(),
|
||||
OnApplyWindowInsetsListener {
|
||||
|
||||
protected val settings by inject<AppSettings>(mode = LazyThreadSafetyMode.NONE)
|
||||
|
||||
@@ -36,4 +36,4 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
|
||||
)
|
||||
return insets
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,10 @@ import android.widget.BaseAdapter
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.databinding.ItemStorageBinding
|
||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.utils.ext.getStorageName
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import org.koitharu.kotatsu.utils.ext.inflate
|
||||
import java.io.File
|
||||
|
||||
@@ -20,15 +20,18 @@ class StorageSelectDialog private constructor(private val delegate: AlertDialog)
|
||||
|
||||
fun show() = delegate.show()
|
||||
|
||||
class Builder(context: Context, defaultValue: File?, listener: OnStorageSelectListener) {
|
||||
class Builder(context: Context, storageManager: LocalStorageManager, listener: OnStorageSelectListener) {
|
||||
|
||||
private val adapter = VolumesAdapter(context)
|
||||
private val adapter = VolumesAdapter(storageManager)
|
||||
private val delegate = MaterialAlertDialogBuilder(context)
|
||||
|
||||
init {
|
||||
if (adapter.isEmpty) {
|
||||
delegate.setMessage(R.string.cannot_find_available_storage)
|
||||
} else {
|
||||
val defaultValue = runBlocking {
|
||||
storageManager.getDefaultWriteableDir()
|
||||
}
|
||||
adapter.selectedItemPosition = adapter.volumes.indexOfFirst {
|
||||
it.first.canonicalPath == defaultValue?.canonicalPath
|
||||
}
|
||||
@@ -57,10 +60,10 @@ class StorageSelectDialog private constructor(private val delegate: AlertDialog)
|
||||
fun create() = StorageSelectDialog(delegate.create())
|
||||
}
|
||||
|
||||
private class VolumesAdapter(context: Context) : BaseAdapter() {
|
||||
private class VolumesAdapter(storageManager: LocalStorageManager) : BaseAdapter() {
|
||||
|
||||
var selectedItemPosition: Int = -1
|
||||
val volumes = getAvailableVolumes(context)
|
||||
val volumes = getAvailableVolumes(storageManager)
|
||||
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val view = convertView ?: parent.inflate(R.layout.item_storage)
|
||||
@@ -82,9 +85,11 @@ class StorageSelectDialog private constructor(private val delegate: AlertDialog)
|
||||
|
||||
override fun hasStableIds() = true
|
||||
|
||||
private fun getAvailableVolumes(context: Context): List<Pair<File, String>> {
|
||||
return LocalMangaRepository.getAvailableStorageDirs(context).map {
|
||||
it to it.getStorageName(context)
|
||||
private fun getAvailableVolumes(storageManager: LocalStorageManager): List<Pair<File, String>> {
|
||||
return runBlocking {
|
||||
storageManager.getWriteableDirs().map {
|
||||
it to storageManager.getStorageDisplayName(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package org.koitharu.kotatsu.core.network
|
||||
|
||||
import okhttp3.CacheControl
|
||||
|
||||
object CommonHeaders {
|
||||
|
||||
const val REFERER = "Referer"
|
||||
@@ -7,4 +9,7 @@ object CommonHeaders {
|
||||
const val ACCEPT = "Accept"
|
||||
const val CONTENT_DISPOSITION = "Content-Disposition"
|
||||
const val COOKIE = "Cookie"
|
||||
}
|
||||
|
||||
val CACHE_CONTROL_DISABLED: CacheControl
|
||||
get() = CacheControl.Builder().noStore().build()
|
||||
}
|
||||
|
||||
@@ -2,27 +2,24 @@ package org.koitharu.kotatsu.core.network
|
||||
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.OkHttpClient
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koin.dsl.bind
|
||||
import org.koin.dsl.module
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.base.domain.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.utils.CacheUtils
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import org.koitharu.kotatsu.utils.DownloadManagerHelper
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
val networkModule
|
||||
get() = module {
|
||||
single { AndroidCookieJar() } bind CookieJar::class
|
||||
single(named(CacheUtils.QUALIFIER_HTTP)) { CacheUtils.createHttpCache(androidContext()) }
|
||||
single {
|
||||
OkHttpClient.Builder().apply {
|
||||
connectTimeout(20, TimeUnit.SECONDS)
|
||||
readTimeout(60, TimeUnit.SECONDS)
|
||||
writeTimeout(20, TimeUnit.SECONDS)
|
||||
cookieJar(get())
|
||||
cache(get(named(CacheUtils.QUALIFIER_HTTP)))
|
||||
cache(get<LocalStorageManager>().createHttpCache())
|
||||
addInterceptor(UserAgentInterceptor())
|
||||
addInterceptor(CloudFlareInterceptor())
|
||||
if (BuildConfig.DEBUG) {
|
||||
@@ -32,4 +29,4 @@ val networkModule
|
||||
}
|
||||
factory { DownloadManagerHelper(get(), get()) }
|
||||
single { MangaLoaderContext(get(), get()) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,10 +64,10 @@ abstract class RemoteMangaRepository(
|
||||
protected fun generateUid(url: String): Long {
|
||||
var h = 1125899906842597L
|
||||
source.name.forEach { c ->
|
||||
h = 31 * h + c.toLong()
|
||||
h = 31 * h + c.code
|
||||
}
|
||||
url.forEach { c ->
|
||||
h = 31 * h + c.toLong()
|
||||
h = 31 * h + c.code
|
||||
}
|
||||
return h
|
||||
}
|
||||
@@ -75,7 +75,7 @@ abstract class RemoteMangaRepository(
|
||||
protected fun generateUid(id: Long): Long {
|
||||
var h = 1125899906842597L
|
||||
source.name.forEach { c ->
|
||||
h = 31 * h + c.toLong()
|
||||
h = 31 * h + c.code
|
||||
}
|
||||
h = 31 * h + id
|
||||
return h
|
||||
@@ -84,4 +84,4 @@ abstract class RemoteMangaRepository(
|
||||
protected fun parseFailed(message: String? = null): Nothing {
|
||||
throw ParseException(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ 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.DateFormat
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
@@ -32,7 +33,7 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
|
||||
offset: Int,
|
||||
query: String?,
|
||||
tags: Set<MangaTag>?,
|
||||
sortOrder: SortOrder?
|
||||
sortOrder: SortOrder?,
|
||||
): List<Manga> {
|
||||
copyCookies()
|
||||
val domain = getDomain()
|
||||
@@ -97,9 +98,7 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
|
||||
}
|
||||
val branchId = content.getJSONArray("branches").optJSONObject(0)
|
||||
?.getLong("id") ?: throw ParseException("No branches found")
|
||||
val chapters = loaderContext.httpGet(
|
||||
url = "https://api.$domain/api/titles/chapters/?branch_id=$branchId"
|
||||
).parseJson().getJSONArray("content")
|
||||
val chapters = grabChapters(domain, branchId)
|
||||
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US)
|
||||
return manga.copy(
|
||||
description = content.getString("description"),
|
||||
@@ -118,11 +117,11 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
|
||||
chapters = chapters.mapIndexed { i, jo ->
|
||||
val id = jo.getLong("id")
|
||||
val name = jo.getString("name").toTitleCase(Locale.ROOT)
|
||||
val publishers = jo.getJSONArray("publishers")
|
||||
val publishers = jo.optJSONArray("publishers")
|
||||
MangaChapter(
|
||||
id = generateUid(id),
|
||||
url = "/api/titles/chapters/$id/",
|
||||
number = chapters.length() - i,
|
||||
number = chapters.size - i,
|
||||
name = buildString {
|
||||
append("Том ")
|
||||
append(jo.optString("tome", "0"))
|
||||
@@ -135,7 +134,7 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
|
||||
}
|
||||
},
|
||||
uploadDate = dateFormat.tryParse(jo.getString("upload_date")),
|
||||
scanlator = publishers.optJSONObject(0)?.getStringOrNull("name"),
|
||||
scanlator = publishers?.optJSONObject(0)?.getStringOrNull("name"),
|
||||
source = MangaSource.REMANGA,
|
||||
branch = null,
|
||||
)
|
||||
@@ -146,16 +145,28 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
|
||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||
val referer = "https://${getDomain()}/"
|
||||
val content = loaderContext.httpGet(chapter.url.withDomain(subdomain = "api")).parseJson()
|
||||
.getJSONObject("content").getJSONArray("pages")
|
||||
val pages = ArrayList<MangaPage>(content.length())
|
||||
for (i in 0 until content.length()) {
|
||||
when (val item = content.get(i)) {
|
||||
is JSONObject -> pages += parsePage(item, referer)
|
||||
is JSONArray -> item.mapTo(pages) { parsePage(it, referer) }
|
||||
.getJSONObject("content")
|
||||
val pages = content.optJSONArray("pages")
|
||||
if (pages == null) {
|
||||
val pubDate = content.getStringOrNull("pub_date")?.let {
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US).tryParse(it)
|
||||
}
|
||||
if (pubDate != null && pubDate > System.currentTimeMillis()) {
|
||||
val at = SimpleDateFormat.getDateInstance(DateFormat.LONG).format(Date(pubDate))
|
||||
parseFailed("Глава станет доступной $at")
|
||||
} else {
|
||||
parseFailed("Глава недоступна")
|
||||
}
|
||||
}
|
||||
val result = ArrayList<MangaPage>(pages.length())
|
||||
for (i in 0 until pages.length()) {
|
||||
when (val item = pages.get(i)) {
|
||||
is JSONObject -> result += parsePage(item, referer)
|
||||
is JSONArray -> item.mapTo(result) { parsePage(it, referer) }
|
||||
else -> throw ParseException("Unknown json item $item")
|
||||
}
|
||||
}
|
||||
return pages
|
||||
return result
|
||||
}
|
||||
|
||||
override suspend fun getTags(): Set<MangaTag> {
|
||||
@@ -198,6 +209,26 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
|
||||
source = source,
|
||||
)
|
||||
|
||||
private suspend fun grabChapters(domain: String, branchId: Long): List<JSONObject> {
|
||||
val result = ArrayList<JSONObject>(100)
|
||||
var page = 1
|
||||
while (true) {
|
||||
val content = loaderContext.httpGet(
|
||||
"https://api.$domain/api/titles/chapters/?branch_id=$branchId&page=$page&count=100"
|
||||
).parseJson().getJSONArray("content")
|
||||
val len = content.length()
|
||||
if (len == 0) {
|
||||
break
|
||||
}
|
||||
result.ensureCapacity(result.size + len)
|
||||
for (i in 0 until len) {
|
||||
result.add(content.getJSONObject(i))
|
||||
}
|
||||
page++
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
const val PAGE_SIZE = 30
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.prefs
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
@@ -12,133 +13,149 @@ import com.google.android.material.color.DynamicColors
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.channels.trySendBlocking
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.model.ZoomMode
|
||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.utils.delegates.prefs.*
|
||||
import org.koitharu.kotatsu.utils.ext.toUriOrNull
|
||||
import java.io.File
|
||||
import java.text.DateFormat
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
class AppSettings private constructor(private val prefs: SharedPreferences) :
|
||||
SharedPreferences by prefs {
|
||||
class AppSettings(context: Context) {
|
||||
|
||||
constructor(context: Context) : this(
|
||||
PreferenceManager.getDefaultSharedPreferences(context)
|
||||
)
|
||||
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
|
||||
var listMode by EnumPreferenceDelegate(
|
||||
ListMode::class.java,
|
||||
KEY_LIST_MODE,
|
||||
ListMode.DETAILED_LIST
|
||||
)
|
||||
var listMode: ListMode
|
||||
get() = prefs.getString(KEY_LIST_MODE, null)?.findEnumValue(ListMode.values()) ?: ListMode.DETAILED_LIST
|
||||
set(value) = prefs.edit { putString(KEY_LIST_MODE, value.name) }
|
||||
|
||||
var defaultSection by IntEnumPreferenceDelegate(
|
||||
AppSection::class.java,
|
||||
KEY_APP_SECTION,
|
||||
AppSection.HISTORY
|
||||
)
|
||||
var defaultSection: AppSection
|
||||
get() = prefs.getString(KEY_APP_SECTION, null)?.findEnumValue(AppSection.values()) ?: AppSection.HISTORY
|
||||
set(value) = prefs.edit { putString(KEY_APP_SECTION, value.name) }
|
||||
|
||||
val theme by StringIntPreferenceDelegate(
|
||||
KEY_THEME,
|
||||
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
|
||||
)
|
||||
val theme: Int
|
||||
get() = prefs.getString(KEY_THEME, null)?.toIntOrNull() ?: AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
|
||||
|
||||
val isDynamicTheme by BoolPreferenceDelegate(KEY_DYNAMIC_THEME, defaultValue = false)
|
||||
val isDynamicTheme: Boolean
|
||||
get() = prefs.getBoolean(KEY_DYNAMIC_THEME, false)
|
||||
|
||||
val isAmoledTheme by BoolPreferenceDelegate(KEY_THEME_AMOLED, defaultValue = false)
|
||||
val isAmoledTheme: Boolean
|
||||
get() = prefs.getBoolean(KEY_THEME_AMOLED, false)
|
||||
|
||||
val isToolbarHideWhenScrolling by BoolPreferenceDelegate(KEY_HIDE_TOOLBAR, defaultValue = true)
|
||||
val isToolbarHideWhenScrolling: Boolean
|
||||
get() = prefs.getBoolean(KEY_HIDE_TOOLBAR, true)
|
||||
|
||||
var gridSize by IntPreferenceDelegate(KEY_GRID_SIZE, defaultValue = 100)
|
||||
var gridSize: Int
|
||||
get() = prefs.getInt(KEY_GRID_SIZE, 100)
|
||||
set(value) = prefs.edit { putInt(KEY_GRID_SIZE, value) }
|
||||
|
||||
val readerPageSwitch by StringSetPreferenceDelegate(
|
||||
KEY_READER_SWITCHERS,
|
||||
arraySetOf(PAGE_SWITCH_TAPS)
|
||||
)
|
||||
val readerPageSwitch: Set<String>
|
||||
get() = prefs.getStringSet(KEY_READER_SWITCHERS, null) ?: setOf(PAGE_SWITCH_TAPS)
|
||||
|
||||
var isTrafficWarningEnabled by BoolPreferenceDelegate(KEY_TRAFFIC_WARNING, defaultValue = true)
|
||||
var isTrafficWarningEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_TRAFFIC_WARNING, true)
|
||||
set(value) = prefs.edit { putBoolean(KEY_TRAFFIC_WARNING, value) }
|
||||
|
||||
val appUpdateAuto by BoolPreferenceDelegate(KEY_APP_UPDATE_AUTO, defaultValue = true)
|
||||
val appUpdateAuto: Boolean
|
||||
get() = prefs.getBoolean(KEY_APP_UPDATE_AUTO, true)
|
||||
|
||||
var appUpdate by LongPreferenceDelegate(KEY_APP_UPDATE, defaultValue = 0L)
|
||||
var appUpdate: Long
|
||||
get() = prefs.getLong(KEY_APP_UPDATE, 0L)
|
||||
set(value) = prefs.edit { putLong(KEY_APP_UPDATE, value) }
|
||||
|
||||
val trackerNotifications by BoolPreferenceDelegate(
|
||||
KEY_TRACKER_NOTIFICATIONS,
|
||||
defaultValue = true
|
||||
)
|
||||
val trackerNotifications: Boolean
|
||||
get() = prefs.getBoolean(KEY_TRACKER_NOTIFICATIONS, true)
|
||||
|
||||
var notificationSound by StringPreferenceDelegate(
|
||||
KEY_NOTIFICATIONS_SOUND,
|
||||
Settings.System.DEFAULT_NOTIFICATION_URI.toString()
|
||||
)
|
||||
var notificationSound: Uri
|
||||
get() = prefs.getString(KEY_NOTIFICATIONS_SOUND, null)?.toUriOrNull()
|
||||
?: Settings.System.DEFAULT_NOTIFICATION_URI
|
||||
set(value) = prefs.edit { putString(KEY_NOTIFICATIONS_SOUND, value.toString()) }
|
||||
|
||||
val notificationVibrate by BoolPreferenceDelegate(KEY_NOTIFICATIONS_VIBRATE, false)
|
||||
val notificationVibrate: Boolean
|
||||
get() = prefs.getBoolean(KEY_NOTIFICATIONS_VIBRATE, false)
|
||||
|
||||
val notificationLight by BoolPreferenceDelegate(KEY_NOTIFICATIONS_LIGHT, true)
|
||||
val notificationLight: Boolean
|
||||
get() = prefs.getBoolean(KEY_NOTIFICATIONS_LIGHT, true)
|
||||
|
||||
val readerAnimation by BoolPreferenceDelegate(KEY_READER_ANIMATION, false)
|
||||
val readerAnimation: Boolean
|
||||
get() = prefs.getBoolean(KEY_READER_ANIMATION, false)
|
||||
|
||||
val isPreferRtlReader by BoolPreferenceDelegate(KEY_READER_PREFER_RTL, false)
|
||||
val isPreferRtlReader: Boolean
|
||||
get() = prefs.getBoolean(KEY_READER_PREFER_RTL, false)
|
||||
|
||||
var historyGrouping by BoolPreferenceDelegate(KEY_HISTORY_GROUPING, true)
|
||||
var historyGrouping: Boolean
|
||||
get() = prefs.getBoolean(KEY_HISTORY_GROUPING, true)
|
||||
set(value) = prefs.edit { putBoolean(KEY_HISTORY_GROUPING, value) }
|
||||
|
||||
var isHistoryExcludeNsfw by BoolPreferenceDelegate(KEY_HISTORY_EXCLUDE_NSFW, false)
|
||||
val isHistoryExcludeNsfw: Boolean
|
||||
get() = prefs.getBoolean(KEY_HISTORY_EXCLUDE_NSFW, false)
|
||||
|
||||
var chaptersReverse by BoolPreferenceDelegate(KEY_REVERSE_CHAPTERS, false)
|
||||
var chaptersReverse: Boolean
|
||||
get() = prefs.getBoolean(KEY_REVERSE_CHAPTERS, false)
|
||||
set(value) = prefs.edit { putBoolean(KEY_REVERSE_CHAPTERS, value) }
|
||||
|
||||
val zoomMode by EnumPreferenceDelegate(
|
||||
ZoomMode::class.java,
|
||||
KEY_ZOOM_MODE,
|
||||
ZoomMode.FIT_CENTER
|
||||
)
|
||||
val zoomMode: ZoomMode
|
||||
get() = prefs.getString(KEY_ZOOM_MODE, null)?.findEnumValue(ZoomMode.values()) ?: ZoomMode.FIT_CENTER
|
||||
|
||||
val trackSources by StringSetPreferenceDelegate(
|
||||
KEY_TRACK_SOURCES,
|
||||
arraySetOf(TRACK_FAVOURITES, TRACK_HISTORY)
|
||||
)
|
||||
val trackSources: Set<String>
|
||||
get() = prefs.getStringSet(KEY_TRACK_SOURCES, null) ?: arraySetOf(TRACK_FAVOURITES, TRACK_HISTORY)
|
||||
|
||||
var appPassword by NullableStringPreferenceDelegate(KEY_APP_PASSWORD)
|
||||
|
||||
private var sourcesOrderStr by NullableStringPreferenceDelegate(KEY_SOURCES_ORDER)
|
||||
var appPassword: String?
|
||||
get() = prefs.getString(KEY_APP_PASSWORD, null)
|
||||
set(value) = prefs.edit { if (value != null) putString(KEY_APP_PASSWORD, value) else remove(KEY_APP_PASSWORD) }
|
||||
|
||||
var sourcesOrder: List<Int>
|
||||
get() = sourcesOrderStr?.split('|')?.mapNotNull(String::toIntOrNull).orEmpty()
|
||||
set(value) {
|
||||
sourcesOrderStr = value.joinToString("|")
|
||||
get() = prefs.getString(KEY_SOURCES_ORDER, null)
|
||||
?.split('|')
|
||||
?.mapNotNull(String::toIntOrNull)
|
||||
.orEmpty()
|
||||
set(value) = prefs.edit {
|
||||
putString(KEY_SOURCES_ORDER, value.joinToString("|"))
|
||||
}
|
||||
|
||||
var hiddenSources by StringSetPreferenceDelegate(KEY_SOURCES_HIDDEN)
|
||||
var hiddenSources: Set<String>
|
||||
get() = prefs.getStringSet(KEY_SOURCES_HIDDEN, null) ?: emptySet()
|
||||
set(value) = prefs.edit { putStringSet(KEY_SOURCES_HIDDEN, value) }
|
||||
|
||||
val isSourcesSelected: Boolean
|
||||
get() = KEY_SOURCES_HIDDEN in prefs
|
||||
|
||||
val isPagesNumbersEnabled by BoolPreferenceDelegate(KEY_PAGES_NUMBERS, false)
|
||||
val isPagesNumbersEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_PAGES_NUMBERS, false)
|
||||
|
||||
fun getStorageDir(context: Context): File? {
|
||||
val value = prefs.getString(KEY_LOCAL_STORAGE, null)?.let {
|
||||
var mangaStorageDir: File?
|
||||
get() = prefs.getString(KEY_LOCAL_STORAGE, null)?.let {
|
||||
File(it)
|
||||
}?.takeIf { it.exists() && it.canWrite() }
|
||||
return value ?: LocalMangaRepository.getFallbackStorageDir(context)
|
||||
}
|
||||
|
||||
fun setStorageDir(context: Context, file: File?) {
|
||||
prefs.edit {
|
||||
if (file == null) {
|
||||
}?.takeIf { it.exists() }
|
||||
set(value) = prefs.edit {
|
||||
if (value == null) {
|
||||
remove(KEY_LOCAL_STORAGE)
|
||||
} else {
|
||||
putString(KEY_LOCAL_STORAGE, file.path)
|
||||
putString(KEY_LOCAL_STORAGE, value.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun dateFormat(format: String? = prefs.getString(KEY_DATE_FORMAT, "")): DateFormat =
|
||||
fun getDateFormat(format: String = prefs.getString(KEY_DATE_FORMAT, "").orEmpty()): DateFormat =
|
||||
when (format) {
|
||||
"" -> DateFormat.getDateInstance(DateFormat.SHORT)
|
||||
else -> SimpleDateFormat(format, Locale.getDefault())
|
||||
}
|
||||
|
||||
@Deprecated("Use observe()")
|
||||
fun getMangaSources(includeHidden: Boolean): List<MangaSource> {
|
||||
val list = MangaSource.values().toMutableList()
|
||||
list.remove(MangaSource.LOCAL)
|
||||
val order = sourcesOrder
|
||||
list.sortBy { x ->
|
||||
val e = order.indexOf(x.ordinal)
|
||||
if (e == -1) order.size + x.ordinal else e
|
||||
}
|
||||
if (!includeHidden) {
|
||||
val hidden = hiddenSources
|
||||
list.removeAll { x -> x.name in hidden }
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
fun subscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
|
||||
prefs.registerOnSharedPreferenceChangeListener(listener)
|
||||
}
|
||||
@@ -157,6 +174,10 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
|
||||
}
|
||||
}
|
||||
|
||||
private fun <E : Enum<E>> String.findEnumValue(values: Array<E>): E? {
|
||||
return values.find { it.name == this }
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val PAGE_SWITCH_TAPS = "taps"
|
||||
@@ -166,7 +187,7 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
|
||||
const val TRACK_FAVOURITES = "favourites"
|
||||
|
||||
const val KEY_LIST_MODE = "list_mode_2"
|
||||
const val KEY_APP_SECTION = "app_section"
|
||||
const val KEY_APP_SECTION = "app_section_2"
|
||||
const val KEY_THEME = "theme"
|
||||
const val KEY_DYNAMIC_THEME = "dynamic_theme"
|
||||
const val KEY_THEME_AMOLED = "amoled_theme"
|
||||
@@ -210,14 +231,15 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
|
||||
const val KEY_APP_TRANSLATION = "about_app_translation"
|
||||
const val KEY_APP_GRATITUDES = "about_gratitudes"
|
||||
const val KEY_FEEDBACK_4PDA = "about_feedback_4pda"
|
||||
const val KEY_FEEDBACK_DISCORD = "about_feedback_discord"
|
||||
const val KEY_FEEDBACK_GITHUB = "about_feedback_github"
|
||||
const val KEY_SUPPORT_DEVELOPER = "about_support_developer"
|
||||
|
||||
val isDynamicColorAvailable: Boolean
|
||||
get() = DynamicColors.isDynamicColorAvailable() ||
|
||||
(isSamsung && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
||||
(isSamsung && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
||||
|
||||
private val isSamsung
|
||||
get() = Build.MANUFACTURER.equals("samsung", ignoreCase = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,15 @@
|
||||
package org.koitharu.kotatsu.core.prefs
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import org.koitharu.kotatsu.utils.delegates.prefs.LongPreferenceDelegate
|
||||
import androidx.core.content.edit
|
||||
|
||||
class AppWidgetConfig private constructor(
|
||||
private val prefs: SharedPreferences,
|
||||
val widgetId: Int
|
||||
) : SharedPreferences by prefs {
|
||||
private const val CATEGORY_ID = "cat_id"
|
||||
|
||||
var categoryId by LongPreferenceDelegate(CATEGORY_ID, 0L)
|
||||
class AppWidgetConfig(context: Context, val widgetId: Int) {
|
||||
|
||||
companion object {
|
||||
private val prefs = context.getSharedPreferences("appwidget_$widgetId", Context.MODE_PRIVATE)
|
||||
|
||||
private const val CATEGORY_ID = "cat_id"
|
||||
|
||||
fun getInstance(context: Context, widgetId: Int) = AppWidgetConfig(
|
||||
context.getSharedPreferences(
|
||||
"appwidget_$widgetId",
|
||||
Context.MODE_PRIVATE
|
||||
), widgetId
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
var categoryId: Long
|
||||
get() = prefs.getLong(CATEGORY_ID, 0L)
|
||||
set(value) = prefs.edit { putLong(CATEGORY_ID, value) }
|
||||
}
|
||||
|
||||
@@ -10,4 +10,4 @@ enum class ReaderMode(val id: Int) {
|
||||
|
||||
fun valueOf(id: Int) = values().firstOrNull { it.id == id }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.ui
|
||||
|
||||
import coil.ComponentRegistry
|
||||
import coil.ImageLoader
|
||||
import coil.util.CoilUtils
|
||||
import okhttp3.OkHttpClient
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.dsl.module
|
||||
@@ -11,8 +12,11 @@ import org.koitharu.kotatsu.local.data.CbzFetcher
|
||||
val uiModule
|
||||
get() = module {
|
||||
single {
|
||||
val httpClient = get<OkHttpClient>().newBuilder()
|
||||
.cache(CoilUtils.createDefaultCache(androidContext()))
|
||||
.build()
|
||||
ImageLoader.Builder(androidContext())
|
||||
.okHttpClient(get<OkHttpClient>())
|
||||
.okHttpClient(httpClient)
|
||||
.componentRegistry(
|
||||
ComponentRegistry.Builder()
|
||||
.add(CbzFetcher())
|
||||
|
||||
@@ -47,7 +47,7 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(),
|
||||
TabLayoutMediator.TabConfigurationStrategy {
|
||||
|
||||
private val viewModel by viewModel<DetailsViewModel> {
|
||||
parametersOf(MangaIntent.from(intent))
|
||||
parametersOf(MangaIntent(intent))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -280,4 +280,4 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(),
|
||||
.putExtra(MangaIntent.KEY_ID, mangaId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,9 +15,7 @@ import androidx.core.view.updatePadding
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
import coil.util.CoilUtils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||
import org.koitharu.kotatsu.R
|
||||
@@ -33,7 +31,7 @@ import org.koitharu.kotatsu.image.ui.ImageActivity
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||
import org.koitharu.kotatsu.search.ui.SearchActivity
|
||||
import org.koitharu.kotatsu.utils.FileSizeUtils
|
||||
import org.koitharu.kotatsu.utils.FileSize
|
||||
import org.koitharu.kotatsu.utils.ext.*
|
||||
|
||||
class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickListener,
|
||||
@@ -114,10 +112,8 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
|
||||
val file = manga.url.toUri().toFileOrNull()
|
||||
if (file != null) {
|
||||
viewLifecycleScope.launch {
|
||||
val size = withContext(Dispatchers.IO) {
|
||||
file.length()
|
||||
}
|
||||
textViewSize.text = FileSizeUtils.formatBytes(requireContext(), size)
|
||||
val size = file.computeSize()
|
||||
textViewSize.text = FileSize.BYTES.format(requireContext(), size)
|
||||
}
|
||||
sizeContainer.isVisible = true
|
||||
} else {
|
||||
@@ -270,4 +266,4 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
|
||||
.lifecycle(viewLifecycleOwner)
|
||||
.enqueueWith(coil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ class DetailsViewModel(
|
||||
branch: String?,
|
||||
): List<ChapterListItem> {
|
||||
val result = ArrayList<ChapterListItem>(chapters.size)
|
||||
val dateFormat = settings.dateFormat()
|
||||
val dateFormat = settings.getDateFormat()
|
||||
val currentIndex = chapters.indexOfFirst { it.id == currentId }
|
||||
val firstNewIndex = chapters.size - newCount
|
||||
val downloadedIds = downloadedChapters?.mapToSet { it.id }
|
||||
@@ -196,7 +196,7 @@ class DetailsViewModel(
|
||||
val result = ArrayList<ChapterListItem>(sourceChapters.size)
|
||||
val currentIndex = sourceChapters.indexOfFirst { it.id == currentId }
|
||||
val firstNewIndex = sourceChapters.size - newCount
|
||||
val dateFormat = settings.dateFormat()
|
||||
val dateFormat = settings.getDateFormat()
|
||||
for (i in sourceChapters.indices) {
|
||||
val chapter = sourceChapters[i]
|
||||
if (chapter.branch != branch) {
|
||||
@@ -253,4 +253,4 @@ class DetailsViewModel(
|
||||
}
|
||||
return groups.maxByOrNull { it.value.size }?.key
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,11 +18,9 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.Manga
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.local.data.MangaZip
|
||||
import org.koitharu.kotatsu.local.data.PagesCache
|
||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.utils.CacheUtils
|
||||
import org.koitharu.kotatsu.utils.ext.await
|
||||
import org.koitharu.kotatsu.utils.ext.deleteAwait
|
||||
import org.koitharu.kotatsu.utils.ext.waitForNetwork
|
||||
@@ -30,7 +28,6 @@ import java.io.File
|
||||
|
||||
class DownloadManager(
|
||||
private val context: Context,
|
||||
private val settings: AppSettings,
|
||||
private val imageLoader: ImageLoader,
|
||||
private val okHttp: OkHttpClient,
|
||||
private val cache: PagesCache,
|
||||
@@ -50,7 +47,7 @@ class DownloadManager(
|
||||
fun downloadManga(manga: Manga, chaptersIds: Set<Long>?, startId: Int) = flow<State> {
|
||||
emit(State.Preparing(startId, manga, null))
|
||||
var cover: Drawable? = null
|
||||
val destination = settings.getStorageDir(context)
|
||||
val destination = localMangaRepository.getOutputDir()
|
||||
checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) }
|
||||
var output: MangaZip? = null
|
||||
try {
|
||||
@@ -136,7 +133,7 @@ class DownloadManager(
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.header(CommonHeaders.REFERER, referer)
|
||||
.cacheControl(CacheUtils.CONTROL_DISABLED)
|
||||
.cacheControl(CommonHeaders.CACHE_CONTROL_DISABLED)
|
||||
.get()
|
||||
.build()
|
||||
val call = okHttp.newCall(request)
|
||||
@@ -236,4 +233,4 @@ class DownloadManager(
|
||||
private const val DOWNLOAD_ERROR_DELAY = 500L
|
||||
private const val TEMP_PAGE_FILE = "page.tmp"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ class DownloadService : BaseService() {
|
||||
notificationManager = NotificationManagerCompat.from(this)
|
||||
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager)
|
||||
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading")
|
||||
downloadManager = DownloadManager(this, get(), get(), get(), get(), get())
|
||||
downloadManager = DownloadManager(this, get(), get(), get(), get())
|
||||
DownloadNotification.createChannel(this)
|
||||
registerReceiver(controlReceiver, IntentFilter(ACTION_DOWNLOAD_CANCEL))
|
||||
}
|
||||
|
||||
@@ -6,13 +6,15 @@ import org.koin.core.qualifier.named
|
||||
import org.koin.dsl.module
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.local.ui.LocalListViewModel
|
||||
|
||||
val localModule
|
||||
get() = module {
|
||||
|
||||
single { LocalMangaRepository(androidContext()) }
|
||||
single { LocalStorageManager(androidContext(), get()) }
|
||||
single { LocalMangaRepository(get()) }
|
||||
factory<MangaRepository>(named(MangaSource.LOCAL)) { get<LocalMangaRepository>() }
|
||||
|
||||
viewModel { LocalListViewModel(get(), get(), get(), get()) }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package org.koitharu.kotatsu.local.data
|
||||
|
||||
enum class Cache(val dir: String) {
|
||||
enum class CacheDir(val dir: String) {
|
||||
|
||||
THUMBS("image_cache"),
|
||||
PAGES("pages");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package org.koitharu.kotatsu.local.data
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.os.StatFs
|
||||
import androidx.annotation.WorkerThread
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Cache
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.utils.ext.computeSize
|
||||
import org.koitharu.kotatsu.utils.ext.getStorageName
|
||||
import java.io.File
|
||||
|
||||
private const val DIR_NAME = "manga"
|
||||
private const val CACHE_DISK_PERCENTAGE = 0.02
|
||||
private const val CACHE_SIZE_MIN: Long = 10 * 1024 * 1024 // 10MB
|
||||
private const val CACHE_SIZE_MAX: Long = 250 * 1024 * 1024 // 250MB
|
||||
|
||||
class LocalStorageManager(
|
||||
private val context: Context,
|
||||
private val settings: AppSettings,
|
||||
) {
|
||||
|
||||
val contentResolver: ContentResolver
|
||||
get() = context.contentResolver
|
||||
|
||||
fun createHttpCache(): Cache {
|
||||
val directory = File(context.externalCacheDir ?: context.cacheDir, "http")
|
||||
directory.mkdirs()
|
||||
val maxSize = calculateDiskCacheSize(directory)
|
||||
return Cache(directory, maxSize)
|
||||
}
|
||||
|
||||
suspend fun computeCacheSize(cache: CacheDir) = withContext(Dispatchers.IO) {
|
||||
getCacheDirs(cache.dir).sumOf { it.computeSize() }
|
||||
}
|
||||
|
||||
suspend fun clearCache(cache: CacheDir) = runInterruptible(Dispatchers.IO) {
|
||||
getCacheDirs(cache.dir).forEach { it.deleteRecursively() }
|
||||
}
|
||||
|
||||
suspend fun getReadableDirs(): List<File> = runInterruptible(Dispatchers.IO) {
|
||||
getConfiguredStorageDirs()
|
||||
.filter { it.isReadable() }
|
||||
}
|
||||
|
||||
suspend fun getWriteableDirs(): List<File> = runInterruptible(Dispatchers.IO) {
|
||||
getConfiguredStorageDirs()
|
||||
.filter { it.isWriteable() }
|
||||
}
|
||||
|
||||
suspend fun getDefaultWriteableDir(): File? = runInterruptible(Dispatchers.IO) {
|
||||
val preferredDir = settings.mangaStorageDir?.takeIf { it.isWriteable() }
|
||||
preferredDir ?: getFallbackStorageDir()?.takeIf { it.isWriteable() }
|
||||
}
|
||||
|
||||
fun getStorageDisplayName(file: File) = file.getStorageName(context)
|
||||
|
||||
@WorkerThread
|
||||
private fun getConfiguredStorageDirs(): MutableSet<File> {
|
||||
val set = getAvailableStorageDirs()
|
||||
settings.mangaStorageDir?.let {
|
||||
set.add(it)
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun getAvailableStorageDirs(): MutableSet<File> {
|
||||
val result = LinkedHashSet<File>()
|
||||
result += File(context.filesDir, DIR_NAME)
|
||||
result += context.getExternalFilesDirs(DIR_NAME)
|
||||
result.retainAll { it.exists() || it.mkdirs() }
|
||||
return result
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun getFallbackStorageDir(): File? {
|
||||
return context.getExternalFilesDir(DIR_NAME) ?: File(context.filesDir, DIR_NAME).takeIf {
|
||||
it.exists() || it.mkdirs()
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun getCacheDirs(subDir: String): MutableSet<File> {
|
||||
val result = LinkedHashSet<File>()
|
||||
result += File(context.cacheDir, subDir)
|
||||
context.externalCacheDirs.mapTo(result) {
|
||||
File(it, subDir)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun calculateDiskCacheSize(cacheDirectory: File): Long {
|
||||
return try {
|
||||
val cacheDir = StatFs(cacheDirectory.absolutePath)
|
||||
val size = CACHE_DISK_PERCENTAGE * cacheDir.blockCountLong * cacheDir.blockSizeLong
|
||||
return size.toLong().coerceIn(CACHE_SIZE_MIN, CACHE_SIZE_MAX)
|
||||
} catch (_: Exception) {
|
||||
CACHE_SIZE_MIN
|
||||
}
|
||||
}
|
||||
|
||||
private fun File.isReadable() = runCatching {
|
||||
canRead()
|
||||
}.getOrDefault(false)
|
||||
|
||||
private fun File.isWriteable() = runCatching {
|
||||
canWrite()
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
@@ -16,7 +16,7 @@ class MangaZip(val file: File) {
|
||||
private var index = MangaIndex(null)
|
||||
|
||||
suspend fun prepare(manga: Manga) {
|
||||
writableCbz.prepare()
|
||||
writableCbz.prepare(overwrite = true)
|
||||
index = MangaIndex(writableCbz[INDEX_ENTRY].takeIfReadable()?.readText())
|
||||
index.setMangaInfo(manga, append = true)
|
||||
}
|
||||
|
||||
@@ -2,35 +2,25 @@ package org.koitharu.kotatsu.local.data
|
||||
|
||||
import android.content.Context
|
||||
import com.tomclaw.cache.DiskLruCache
|
||||
import org.koitharu.kotatsu.utils.FileSizeUtils
|
||||
import org.koitharu.kotatsu.utils.FileSize
|
||||
import org.koitharu.kotatsu.utils.ext.longHashCode
|
||||
import org.koitharu.kotatsu.utils.ext.subdir
|
||||
import org.koitharu.kotatsu.utils.ext.takeIfReadable
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
class PagesCache(context: Context) {
|
||||
|
||||
private val cacheDir = context.externalCacheDir ?: context.cacheDir
|
||||
private val lruCache = DiskLruCache.create(
|
||||
cacheDir.subdir(Cache.PAGES.dir),
|
||||
FileSizeUtils.mbToBytes(200)
|
||||
cacheDir.subdir(CacheDir.PAGES.dir),
|
||||
FileSize.MEGABYTES.convert(200, FileSize.BYTES),
|
||||
)
|
||||
|
||||
operator fun get(url: String): File? {
|
||||
return lruCache.get(url)?.takeIfReadable()
|
||||
}
|
||||
|
||||
@Deprecated("Useless lambda")
|
||||
fun put(url: String, writer: (OutputStream) -> Unit): File {
|
||||
val file = File(cacheDir, url.longHashCode().toString())
|
||||
file.outputStream().use(writer)
|
||||
val res = lruCache.put(url, file)
|
||||
file.delete()
|
||||
return res
|
||||
}
|
||||
|
||||
fun put(url: String, inputStream: InputStream): File {
|
||||
val file = File(cacheDir, url.longHashCode().toString())
|
||||
file.outputStream().use { out ->
|
||||
@@ -40,4 +30,4 @@ class PagesCache(context: Context) {
|
||||
file.delete()
|
||||
return res
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,9 +14,13 @@ class WritableCbzFile(private val file: File) {
|
||||
|
||||
private val dir = File(file.parentFile, file.nameWithoutExtension)
|
||||
|
||||
suspend fun prepare() = withContext(Dispatchers.IO) {
|
||||
check(dir.list().isNullOrEmpty()) {
|
||||
"Dir ${dir.name} is not empty"
|
||||
suspend fun prepare(overwrite: Boolean) = withContext(Dispatchers.IO) {
|
||||
if (!dir.list().isNullOrEmpty()) {
|
||||
if (overwrite) {
|
||||
dir.deleteRecursively()
|
||||
} else {
|
||||
throw IllegalStateException("Dir ${dir.name} is not empty")
|
||||
}
|
||||
}
|
||||
if (!dir.exists()) {
|
||||
dir.mkdir()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.koitharu.kotatsu.local.domain
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.collection.ArraySet
|
||||
@@ -10,19 +9,22 @@ import androidx.core.net.toUri
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
|
||||
import org.koitharu.kotatsu.core.model.*
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.local.data.CbzFilter
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import org.koitharu.kotatsu.local.data.MangaIndex
|
||||
import org.koitharu.kotatsu.local.data.MangaZip
|
||||
import org.koitharu.kotatsu.utils.AlphanumComparator
|
||||
import org.koitharu.kotatsu.utils.ext.*
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.*
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
class LocalMangaRepository(private val context: Context) : MangaRepository {
|
||||
class LocalMangaRepository(private val storageManager: LocalStorageManager) : MangaRepository {
|
||||
|
||||
override val source = MangaSource.LOCAL
|
||||
private val filenameFilter = CbzFilter()
|
||||
@@ -149,24 +151,26 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun findSavedManga(remoteManga: Manga): Manga? = runInterruptible(Dispatchers.IO) {
|
||||
suspend fun findSavedManga(remoteManga: Manga): Manga? {
|
||||
val files = getAllFiles()
|
||||
for (file in files) {
|
||||
val index = ZipFile(file).use { zip ->
|
||||
val entry = zip.getEntry(MangaZip.INDEX_ENTRY)
|
||||
entry?.let(zip::readText)?.let(::MangaIndex)
|
||||
} ?: continue
|
||||
val info = index.getMangaInfo() ?: continue
|
||||
if (info.id == remoteManga.id) {
|
||||
val fileUri = file.toUri().toString()
|
||||
return@runInterruptible info.copy(
|
||||
source = MangaSource.LOCAL,
|
||||
url = fileUri,
|
||||
chapters = info.chapters?.map { c -> c.copy(url = fileUri) }
|
||||
)
|
||||
return runInterruptible(Dispatchers.IO) {
|
||||
for (file in files) {
|
||||
val index = ZipFile(file).use { zip ->
|
||||
val entry = zip.getEntry(MangaZip.INDEX_ENTRY)
|
||||
entry?.let(zip::readText)?.let(::MangaIndex)
|
||||
} ?: continue
|
||||
val info = index.getMangaInfo() ?: continue
|
||||
if (info.id == remoteManga.id) {
|
||||
val fileUri = file.toUri().toString()
|
||||
return@runInterruptible info.copy(
|
||||
source = MangaSource.LOCAL,
|
||||
url = fileUri,
|
||||
chapters = info.chapters?.map { c -> c.copy(url = fileUri) }
|
||||
)
|
||||
}
|
||||
}
|
||||
null
|
||||
}
|
||||
null
|
||||
}
|
||||
|
||||
private fun zipUri(file: File, entryName: String) =
|
||||
@@ -193,32 +197,38 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
|
||||
|
||||
override suspend fun getTags() = emptySet<MangaTag>()
|
||||
|
||||
private fun getAllFiles() = getAvailableStorageDirs(context).flatMap { dir ->
|
||||
suspend fun import(uri: Uri) {
|
||||
val contentResolver = storageManager.contentResolver
|
||||
withContext(Dispatchers.IO) {
|
||||
val name = contentResolver.resolveName(uri)
|
||||
?: throw IOException("Cannot fetch name from uri: $uri")
|
||||
if (!isFileSupported(name)) {
|
||||
throw UnsupportedFileException("Unsupported file on $uri")
|
||||
}
|
||||
val dest = File(
|
||||
getOutputDir() ?: throw IOException("External files dir unavailable"),
|
||||
name,
|
||||
)
|
||||
runInterruptible {
|
||||
contentResolver.openInputStream(uri)?.use { source ->
|
||||
dest.outputStream().use { output ->
|
||||
source.copyTo(output)
|
||||
}
|
||||
}
|
||||
} ?: throw IOException("Cannot open input stream: $uri")
|
||||
}
|
||||
}
|
||||
|
||||
fun isFileSupported(name: String): Boolean {
|
||||
val ext = name.substringAfterLast('.').lowercase(Locale.ROOT)
|
||||
return ext == "cbz" || ext == "zip"
|
||||
}
|
||||
|
||||
suspend fun getOutputDir(): File? {
|
||||
return storageManager.getDefaultWriteableDir()
|
||||
}
|
||||
|
||||
private suspend fun getAllFiles() = storageManager.getReadableDirs().flatMap { dir ->
|
||||
dir.listFiles(filenameFilter)?.toList().orEmpty()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val DIR_NAME = "manga"
|
||||
|
||||
fun isFileSupported(name: String): Boolean {
|
||||
val ext = name.substringAfterLast('.').lowercase(Locale.ROOT)
|
||||
return ext == "cbz" || ext == "zip"
|
||||
}
|
||||
|
||||
fun getAvailableStorageDirs(context: Context): List<File> {
|
||||
val result = ArrayList<File?>(5)
|
||||
result += File(context.filesDir, DIR_NAME)
|
||||
result += context.getExternalFilesDirs(DIR_NAME)
|
||||
return result.filterNotNull()
|
||||
.distinctBy { it.canonicalPath }
|
||||
.filter { it.exists() || it.mkdir() }
|
||||
}
|
||||
|
||||
fun getFallbackStorageDir(context: Context): File? {
|
||||
return context.getExternalFilesDir(DIR_NAME) ?: context.filesDir.sub(DIR_NAME).takeIf {
|
||||
(it.exists() || it.mkdir()) && it.canWrite()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,14 +18,16 @@ import org.koitharu.kotatsu.core.model.Manga
|
||||
import org.koitharu.kotatsu.download.ui.service.DownloadService
|
||||
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
||||
import org.koitharu.kotatsu.utils.ext.ellipsize
|
||||
import org.koitharu.kotatsu.utils.progress.Progress
|
||||
|
||||
class LocalListFragment : MangaListFragment(), ActivityResultCallback<Uri?> {
|
||||
class LocalListFragment : MangaListFragment(), ActivityResultCallback<List<@JvmSuppressWildcards Uri>> {
|
||||
|
||||
override val viewModel by viewModel<LocalListViewModel>()
|
||||
private val importCall = registerForActivityResult(
|
||||
ActivityResultContracts.OpenDocument(),
|
||||
ActivityResultContracts.OpenMultipleDocuments(),
|
||||
this
|
||||
)
|
||||
private var importSnackbar: Snackbar? = null
|
||||
private val downloadReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action == DownloadService.ACTION_DOWNLOAD_COMPLETE) {
|
||||
@@ -45,6 +47,12 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback<Uri?> {
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
viewModel.onMangaRemoved.observe(viewLifecycleOwner, ::onItemRemoved)
|
||||
viewModel.importProgress.observe(viewLifecycleOwner, ::onImportProgressChanged)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
importSnackbar = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun onDetach() {
|
||||
@@ -84,10 +92,9 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback<Uri?> {
|
||||
return context?.getString(R.string.local_storage)
|
||||
}
|
||||
|
||||
override fun onActivityResult(result: Uri?) {
|
||||
if (result != null) {
|
||||
viewModel.importFile(context?.applicationContext ?: return, result)
|
||||
}
|
||||
override fun onActivityResult(result: List<@JvmSuppressWildcards Uri>) {
|
||||
if (result.isEmpty()) return
|
||||
viewModel.importFiles(result)
|
||||
}
|
||||
|
||||
override fun onCreatePopupMenu(inflater: MenuInflater, menu: Menu, data: Manga) {
|
||||
@@ -121,6 +128,25 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback<Uri?> {
|
||||
).show()
|
||||
}
|
||||
|
||||
private fun onImportProgressChanged(progress: Progress?) {
|
||||
if (progress == null) {
|
||||
importSnackbar?.dismiss()
|
||||
importSnackbar = null
|
||||
return
|
||||
}
|
||||
val summaryText = getString(
|
||||
R.string.importing_progress,
|
||||
progress.value + 1,
|
||||
progress.total,
|
||||
)
|
||||
importSnackbar?.setText(summaryText) ?: run {
|
||||
val snackbar =
|
||||
Snackbar.make(binding.recyclerView, summaryText, Snackbar.LENGTH_INDEFINITE)
|
||||
importSnackbar = snackbar
|
||||
snackbar.show()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun newInstance() = LocalListFragment()
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
package org.koitharu.kotatsu.local.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
|
||||
import org.koitharu.kotatsu.core.model.Manga
|
||||
import org.koitharu.kotatsu.core.os.ShortcutsRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
@@ -19,21 +18,22 @@ import org.koitharu.kotatsu.list.ui.model.*
|
||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||
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 org.koitharu.kotatsu.utils.progress.Progress
|
||||
import java.io.IOException
|
||||
|
||||
class LocalListViewModel(
|
||||
private val repository: LocalMangaRepository,
|
||||
private val historyRepository: HistoryRepository,
|
||||
private val settings: AppSettings,
|
||||
settings: AppSettings,
|
||||
private val shortcutsRepository: ShortcutsRepository,
|
||||
) : MangaListViewModel(settings) {
|
||||
|
||||
val onMangaRemoved = SingleLiveEvent<Manga>()
|
||||
val importProgress = MutableLiveData<Progress?>(null)
|
||||
private val listError = MutableStateFlow<Throwable?>(null)
|
||||
private val mangaList = MutableStateFlow<List<Manga>?>(null)
|
||||
private val headerModel = ListHeader(null, R.string.local_storage)
|
||||
private var importJob: Job? = null
|
||||
|
||||
override val content = combine(
|
||||
mangaList,
|
||||
@@ -60,37 +60,23 @@ class LocalListViewModel(
|
||||
|
||||
override fun onRefresh() {
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
try {
|
||||
listError.value = null
|
||||
mangaList.value = repository.getList2(0)
|
||||
} catch (e: Throwable) {
|
||||
listError.value = e
|
||||
}
|
||||
doRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRetry() = onRefresh()
|
||||
|
||||
fun importFile(context: Context, uri: Uri) {
|
||||
launchLoadingJob {
|
||||
val contentResolver = context.contentResolver
|
||||
withContext(Dispatchers.IO) {
|
||||
val name = contentResolver.resolveName(uri)
|
||||
?: throw IOException("Cannot fetch name from uri: $uri")
|
||||
if (!LocalMangaRepository.isFileSupported(name)) {
|
||||
throw UnsupportedFileException("Unsupported file on $uri")
|
||||
}
|
||||
val dest = settings.getStorageDir(context)?.let { File(it, name) }
|
||||
?: throw IOException("External files dir unavailable")
|
||||
runInterruptible {
|
||||
contentResolver.openInputStream(uri)?.use { source ->
|
||||
dest.outputStream().use { output ->
|
||||
source.copyTo(output)
|
||||
}
|
||||
}
|
||||
} ?: throw IOException("Cannot open input stream: $uri")
|
||||
fun importFiles(uris: List<Uri>) {
|
||||
val previousJob = importJob
|
||||
importJob = launchJob(Dispatchers.Default) {
|
||||
previousJob?.join()
|
||||
importProgress.postValue(Progress(0, uris.size))
|
||||
for ((i, uri) in uris.withIndex()) {
|
||||
repository.import(uri)
|
||||
importProgress.postValue(Progress(i + 1, uris.size))
|
||||
doRefresh()
|
||||
}
|
||||
onRefresh()
|
||||
importProgress.postValue(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,4 +94,13 @@ class LocalListViewModel(
|
||||
onMangaRemoved.call(manga)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun doRefresh() {
|
||||
try {
|
||||
listError.value = null
|
||||
mangaList.value = repository.getList2(0)
|
||||
} catch (e: Throwable) {
|
||||
listError.value = e
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import org.koitharu.kotatsu.base.domain.MangaProviderFactory
|
||||
import org.koitharu.kotatsu.base.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
|
||||
import org.koitharu.kotatsu.core.model.Manga
|
||||
@@ -25,7 +24,7 @@ class MainViewModel(
|
||||
val remoteSources = settings.observe()
|
||||
.filter { it == AppSettings.KEY_SOURCES_ORDER || it == AppSettings.KEY_SOURCES_HIDDEN }
|
||||
.onStart { emit("") }
|
||||
.map { MangaProviderFactory.getSources(settings, includeHidden = false) }
|
||||
.map { settings.getMangaSources(includeHidden = false) }
|
||||
.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
|
||||
|
||||
fun openLastReader() {
|
||||
@@ -35,4 +34,4 @@ class MainViewModel(
|
||||
onOpenReader.call(manga)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import org.koitharu.kotatsu.core.model.MangaPage
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.local.data.PagesCache
|
||||
import org.koitharu.kotatsu.utils.CacheUtils
|
||||
import org.koitharu.kotatsu.utils.ext.await
|
||||
import org.koitharu.kotatsu.utils.ext.mangaRepositoryOf
|
||||
import java.io.File
|
||||
@@ -70,7 +69,7 @@ class PageLoader(
|
||||
.get()
|
||||
.header(CommonHeaders.REFERER, page.referer)
|
||||
.header(CommonHeaders.ACCEPT, "image/webp,image/png;q=0.9,image/jpeg,*/*;q=0.8")
|
||||
.cacheControl(CacheUtils.CONTROL_DISABLED)
|
||||
.cacheControl(CommonHeaders.CACHE_CONTROL_DISABLED)
|
||||
.build()
|
||||
okHttp.newCall(request).await().use { response ->
|
||||
check(response.isSuccessful) {
|
||||
@@ -103,4 +102,4 @@ class PageLoader(
|
||||
}
|
||||
|
||||
private companion object Lock
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ class ChaptersDialog : AlertDialogFragment<DialogChaptersBinding>(),
|
||||
}
|
||||
val currentId = arguments?.getLong(ARG_CURRENT_ID, 0L) ?: 0L
|
||||
val currentPosition = chapters.indexOfFirst { it.id == currentId }
|
||||
val dateFormat = get<AppSettings>().dateFormat()
|
||||
val dateFormat = get<AppSettings>().getDateFormat()
|
||||
binding.recyclerViewChapters.adapter = ChaptersAdapter(this).apply {
|
||||
setItems(chapters.mapIndexed { index, chapter ->
|
||||
chapter.toListItem(
|
||||
@@ -96,4 +96,4 @@ class ChaptersDialog : AlertDialogFragment<DialogChaptersBinding>(),
|
||||
putLong(ARG_CURRENT_ID, currentId)
|
||||
}.show(fm, TAG)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ class ReaderActivity : BaseFullscreenActivity<ActivityReaderBinding>(),
|
||||
ActivityResultCallback<Boolean>, ReaderControlDelegate.OnInteractionListener {
|
||||
|
||||
private val viewModel by viewModel<ReaderViewModel> {
|
||||
parametersOf(MangaIntent.from(intent), intent?.getParcelableExtra<ReaderState>(EXTRA_STATE))
|
||||
parametersOf(MangaIntent(intent), intent?.getParcelableExtra<ReaderState>(EXTRA_STATE))
|
||||
}
|
||||
|
||||
private lateinit var touchHelper: GridTouchHelper
|
||||
@@ -371,4 +371,4 @@ class ReaderActivity : BaseFullscreenActivity<ActivityReaderBinding>(),
|
||||
.putExtra(EXTRA_STATE, state)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@ class ReaderViewModel(
|
||||
val downloadId = downloadManagerHelper.downloadPage(page, pageUrl)
|
||||
val uri = downloadManagerHelper.awaitDownload(downloadId)
|
||||
onPageSaved.postCall(uri)
|
||||
} catch (e: CancellationException) {
|
||||
} catch (_: CancellationException) {
|
||||
} catch (e: Exception) {
|
||||
onPageSaved.postCall(null)
|
||||
}
|
||||
@@ -267,4 +267,4 @@ class ReaderViewModel(
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ class RemoteListViewModel(
|
||||
when {
|
||||
list.isNullOrEmpty() && error != null -> listOf(error.toErrorState(canRetry = true))
|
||||
list == null -> listOf(LoadingState)
|
||||
list.isEmpty() -> listOf(EmptyState(R.drawable.ic_book_cross, R.string.nothing_found, R.string._empty))
|
||||
list.isEmpty() -> listOf(EmptyState(R.drawable.ic_book_cross, R.string.nothing_found, R.string.empty))
|
||||
else -> {
|
||||
val result = ArrayList<ListModel>(list.size + 3)
|
||||
result += headerModel
|
||||
@@ -128,4 +128,4 @@ class RemoteListViewModel(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.base.domain.MangaProviderFactory
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.model.Manga
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
@@ -27,7 +26,7 @@ class MangaSearchRepository(
|
||||
) {
|
||||
|
||||
fun globalSearch(query: String, concurrency: Int = DEFAULT_CONCURRENCY): Flow<Manga> =
|
||||
MangaProviderFactory.getSources(settings, includeHidden = false).asFlow()
|
||||
settings.getMangaSources(includeHidden = false).asFlow()
|
||||
.flatMapMerge(concurrency) { source ->
|
||||
runCatching {
|
||||
MangaRepository(source).getList2(
|
||||
@@ -128,4 +127,4 @@ class MangaSearchRepository(
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import org.koitharu.kotatsu.core.github.AppVersion
|
||||
import org.koitharu.kotatsu.core.github.GithubRepository
|
||||
import org.koitharu.kotatsu.core.github.VersionId
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.utils.FileSizeUtils
|
||||
import org.koitharu.kotatsu.utils.FileSize
|
||||
import org.koitharu.kotatsu.utils.ext.byte2HexFormatted
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.InputStream
|
||||
@@ -85,7 +85,7 @@ class AppUpdateChecker(private val activity: ComponentActivity) {
|
||||
append(
|
||||
activity.getString(
|
||||
R.string.size_s,
|
||||
FileSizeUtils.formatBytes(activity, version.apkSize)
|
||||
FileSize.BYTES.format(activity, version.apkSize),
|
||||
)
|
||||
)
|
||||
appendLine()
|
||||
@@ -144,4 +144,4 @@ class AppUpdateChecker(private val activity: ComponentActivity) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,20 +5,18 @@ import android.view.View
|
||||
import androidx.preference.Preference
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.android.ext.android.get
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.core.network.AndroidCookieJar
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.local.data.Cache
|
||||
import org.koitharu.kotatsu.local.data.CacheDir
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
|
||||
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
||||
import org.koitharu.kotatsu.utils.CacheUtils
|
||||
import org.koitharu.kotatsu.utils.FileSizeUtils
|
||||
import org.koitharu.kotatsu.utils.FileSize
|
||||
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
|
||||
|
||||
@@ -26,6 +24,7 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
|
||||
|
||||
private val trackerRepo by inject<TrackingRepository>(mode = LazyThreadSafetyMode.NONE)
|
||||
private val searchRepository by inject<MangaSearchRepository>(mode = LazyThreadSafetyMode.NONE)
|
||||
private val storageManager by inject<LocalStorageManager>(mode = LazyThreadSafetyMode.NONE)
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResource(R.xml.pref_history)
|
||||
@@ -33,22 +32,8 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
findPreference<Preference>(AppSettings.KEY_PAGES_CACHE_CLEAR)?.let { pref ->
|
||||
viewLifecycleScope.launchWhenResumed {
|
||||
val size = withContext(Dispatchers.IO) {
|
||||
CacheUtils.computeCacheSize(pref.context, Cache.PAGES.dir)
|
||||
}
|
||||
pref.summary = FileSizeUtils.formatBytes(pref.context, size)
|
||||
}
|
||||
}
|
||||
findPreference<Preference>(AppSettings.KEY_THUMBS_CACHE_CLEAR)?.let { pref ->
|
||||
viewLifecycleScope.launchWhenResumed {
|
||||
val size = withContext(Dispatchers.IO) {
|
||||
CacheUtils.computeCacheSize(pref.context, Cache.THUMBS.dir)
|
||||
}
|
||||
pref.summary = FileSizeUtils.formatBytes(pref.context, size)
|
||||
}
|
||||
}
|
||||
findPreference<Preference>(AppSettings.KEY_PAGES_CACHE_CLEAR)?.bindSummaryToCacheSize(CacheDir.PAGES)
|
||||
findPreference<Preference>(AppSettings.KEY_THUMBS_CACHE_CLEAR)?.bindSummaryToCacheSize(CacheDir.THUMBS)
|
||||
findPreference<Preference>(AppSettings.KEY_SEARCH_HISTORY_CLEAR)?.let { pref ->
|
||||
viewLifecycleScope.launchWhenResumed {
|
||||
val items = searchRepository.getSearchHistoryCount()
|
||||
@@ -68,11 +53,11 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
|
||||
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
||||
return when (preference.key) {
|
||||
AppSettings.KEY_PAGES_CACHE_CLEAR -> {
|
||||
clearCache(preference, Cache.PAGES)
|
||||
clearCache(preference, CacheDir.PAGES)
|
||||
true
|
||||
}
|
||||
AppSettings.KEY_THUMBS_CACHE_CLEAR -> {
|
||||
clearCache(preference, Cache.THUMBS)
|
||||
clearCache(preference, CacheDir.THUMBS)
|
||||
true
|
||||
}
|
||||
AppSettings.KEY_COOKIES_CLEAR -> {
|
||||
@@ -100,16 +85,14 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearCache(preference: Preference, cache: Cache) {
|
||||
private fun clearCache(preference: Preference, cache: CacheDir) {
|
||||
val ctx = preference.context.applicationContext
|
||||
viewLifecycleScope.launch {
|
||||
try {
|
||||
preference.isEnabled = false
|
||||
val size = withContext(Dispatchers.IO) {
|
||||
CacheUtils.clearCache(ctx, cache.dir)
|
||||
CacheUtils.computeCacheSize(ctx, cache.dir)
|
||||
}
|
||||
preference.summary = FileSizeUtils.formatBytes(ctx, size)
|
||||
storageManager.clearCache(cache)
|
||||
val size = storageManager.computeCacheSize(cache)
|
||||
preference.summary = FileSize.BYTES.format(ctx, size)
|
||||
} catch (e: Exception) {
|
||||
preference.summary = e.getDisplayMessage(ctx.resources)
|
||||
} finally {
|
||||
@@ -118,6 +101,11 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
|
||||
}
|
||||
}
|
||||
|
||||
private fun Preference.bindSummaryToCacheSize(dir: CacheDir) = viewLifecycleScope.launch {
|
||||
val size = storageManager.computeCacheSize(dir)
|
||||
summary = FileSize.BYTES.format(context, size)
|
||||
}
|
||||
|
||||
private fun clearSearchHistory(preference: Preference) {
|
||||
MaterialAlertDialogBuilder(context ?: return)
|
||||
.setTitle(R.string.clear_search_history)
|
||||
@@ -154,4 +142,4 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
|
||||
}
|
||||
}.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,18 +12,22 @@ import androidx.preference.ListPreference
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.PreferenceScreen
|
||||
import androidx.preference.SwitchPreference
|
||||
import kotlinx.coroutines.launch
|
||||
import leakcanary.LeakCanary
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.base.ui.dialog.StorageSelectDialog
|
||||
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.local.data.LocalStorageManager
|
||||
import org.koitharu.kotatsu.settings.protect.ProtectSetupActivity
|
||||
import org.koitharu.kotatsu.settings.utils.SliderPreference
|
||||
import org.koitharu.kotatsu.utils.ext.getStorageName
|
||||
import org.koitharu.kotatsu.utils.ext.names
|
||||
import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat
|
||||
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
|
||||
@@ -32,6 +36,8 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
|
||||
SharedPreferences.OnSharedPreferenceChangeListener,
|
||||
StorageSelectDialog.OnStorageSelectListener {
|
||||
|
||||
private val storageManager by inject<LocalStorageManager>()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setHasOptionsMenu(true)
|
||||
@@ -56,7 +62,7 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
|
||||
entryValues = arrayOf("", "MM/dd/yy", "dd/MM/yy", "yyyy-MM-dd", "dd MMM yyyy", "MMM dd, yyyy")
|
||||
val now = Date().time
|
||||
entries = entryValues.map { value ->
|
||||
val formattedDate = settings.dateFormat(value.toString()).format(now)
|
||||
val formattedDate = settings.getDateFormat(value.toString()).format(now)
|
||||
if (value == "") {
|
||||
"${context.getString(R.string.system_default)} ($formattedDate)"
|
||||
} else {
|
||||
@@ -70,10 +76,7 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
findPreference<Preference>(AppSettings.KEY_LOCAL_STORAGE)?.run {
|
||||
summary = settings.getStorageDir(context)?.getStorageName(context)
|
||||
?: getString(R.string.not_available)
|
||||
}
|
||||
findPreference<Preference>(AppSettings.KEY_LOCAL_STORAGE)?.bindStorageName()
|
||||
findPreference<SwitchPreference>(AppSettings.KEY_PROTECT_APP)?.isChecked =
|
||||
!settings.appPassword.isNullOrEmpty()
|
||||
settings.subscribe(this)
|
||||
@@ -114,10 +117,7 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
|
||||
findPreference<SwitchPreference>(key)?.setSummary(R.string.restart_required)
|
||||
}
|
||||
AppSettings.KEY_LOCAL_STORAGE -> {
|
||||
findPreference<Preference>(key)?.run {
|
||||
summary = settings.getStorageDir(context)?.getStorageName(context)
|
||||
?: getString(R.string.not_available)
|
||||
}
|
||||
findPreference<Preference>(key)?.bindStorageName()
|
||||
}
|
||||
AppSettings.KEY_APP_PASSWORD -> {
|
||||
findPreference<SwitchPreference>(AppSettings.KEY_PROTECT_APP)
|
||||
@@ -140,7 +140,7 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
|
||||
return when (preference.key) {
|
||||
AppSettings.KEY_LOCAL_STORAGE -> {
|
||||
val ctx = context ?: return false
|
||||
StorageSelectDialog.Builder(ctx, settings.getStorageDir(ctx), this)
|
||||
StorageSelectDialog.Builder(ctx, storageManager, this)
|
||||
.setTitle(preference.title ?: "")
|
||||
.setNegativeButton(android.R.string.cancel)
|
||||
.create()
|
||||
@@ -162,7 +162,13 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
|
||||
}
|
||||
|
||||
override fun onStorageSelected(file: File) {
|
||||
settings.setStorageDir(context ?: return, file)
|
||||
settings.mangaStorageDir = file
|
||||
}
|
||||
|
||||
}
|
||||
private fun Preference.bindStorageName() {
|
||||
viewLifecycleScope.launch {
|
||||
val storage = storageManager.getDefaultWriteableDir()
|
||||
summary = storage?.getStorageName(context) ?: getString(R.string.not_available)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,14 +10,13 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.settings.utils.RingtonePickContract
|
||||
import org.koitharu.kotatsu.utils.ext.toUriOrNull
|
||||
|
||||
class NotificationSettingsLegacyFragment : BasePreferenceFragment(R.string.notifications) {
|
||||
|
||||
private val ringtonePickContract = registerForActivityResult(
|
||||
RingtonePickContract(get<Context>().getString(R.string.notification_sound))
|
||||
) { uri ->
|
||||
settings.notificationSound = uri?.toString() ?: return@registerForActivityResult
|
||||
settings.notificationSound = uri ?: return@registerForActivityResult
|
||||
findPreference<Preference>(AppSettings.KEY_NOTIFICATIONS_SOUND)?.run {
|
||||
summary = RingtoneManager.getRingtone(context, uri)?.getTitle(context)
|
||||
?: getString(R.string.silent)
|
||||
@@ -31,7 +30,7 @@ class NotificationSettingsLegacyFragment : BasePreferenceFragment(R.string.notif
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
findPreference<Preference>(AppSettings.KEY_NOTIFICATIONS_SOUND)?.run {
|
||||
val uri = settings.notificationSound.toUriOrNull()
|
||||
val uri = settings.notificationSound
|
||||
summary = RingtoneManager.getRingtone(context, uri)?.getTitle(context)
|
||||
?: getString(R.string.silent)
|
||||
}
|
||||
@@ -40,10 +39,10 @@ class NotificationSettingsLegacyFragment : BasePreferenceFragment(R.string.notif
|
||||
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
||||
return when (preference.key) {
|
||||
AppSettings.KEY_NOTIFICATIONS_SOUND -> {
|
||||
ringtonePickContract.launch(settings.notificationSound.toUriOrNull())
|
||||
ringtonePickContract.launch(settings.notificationSound)
|
||||
true
|
||||
}
|
||||
else -> super.onPreferenceTreeClick(preference)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,12 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
|
||||
resources.getString(R.string.about_feedback_4pda)) })
|
||||
true
|
||||
}
|
||||
AppSettings.KEY_FEEDBACK_DISCORD -> {
|
||||
startActivity(context?.let { BrowserActivity.newIntent(it,
|
||||
"https://discord.gg/NNJ5RgVBC5",
|
||||
"Discord") })
|
||||
true
|
||||
}
|
||||
AppSettings.KEY_FEEDBACK_GITHUB -> {
|
||||
startActivity(context?.let { BrowserActivity.newIntent(it,
|
||||
"https://github.com/nv95/Kotatsu/issues",
|
||||
@@ -89,4 +95,4 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.settings.sources
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.domain.MangaProviderFactory
|
||||
import org.koitharu.kotatsu.base.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
@@ -70,7 +69,7 @@ class SourcesSettingsViewModel(
|
||||
}
|
||||
|
||||
private fun buildList() {
|
||||
val sources = MangaProviderFactory.getSources(settings, includeHidden = true)
|
||||
val sources = settings.getMangaSources(includeHidden = true)
|
||||
val hiddenSources = settings.hiddenSources
|
||||
val query = searchQuery
|
||||
if (!query.isNullOrEmpty()) {
|
||||
@@ -155,4 +154,4 @@ class SourcesSettingsViewModel(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.koitharu.kotatsu.tracker
|
||||
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.dsl.module
|
||||
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
||||
@@ -11,5 +10,5 @@ val trackerModule
|
||||
|
||||
single { TrackingRepository(get()) }
|
||||
|
||||
viewModel { FeedViewModel(androidContext(), get()) }
|
||||
viewModel { FeedViewModel(get()) }
|
||||
}
|
||||
@@ -4,7 +4,9 @@ import android.os.Bundle
|
||||
import android.view.*
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.divider.MaterialDividerItemDecoration
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.koin.android.ext.android.get
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
@@ -50,6 +52,9 @@ class FeedFragment : BaseFragment<FragmentFeedBinding>(), PaginationScrollListen
|
||||
adapter = feedAdapter
|
||||
setHasFixedSize(true)
|
||||
addOnScrollListener(PaginationScrollListener(4, this@FeedFragment))
|
||||
val dividerDecoration = MaterialDividerItemDecoration(context, RecyclerView.VERTICAL)
|
||||
dividerDecoration.setDividerInsetStartResource(context, R.dimen.feed_dividers_offset)
|
||||
addItemDecoration(dividerDecoration)
|
||||
}
|
||||
|
||||
viewModel.content.observe(viewLifecycleOwner, this::onListChanged)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.koitharu.kotatsu.tracker.ui
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -22,7 +21,6 @@ import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
||||
import org.koitharu.kotatsu.utils.ext.mapItems
|
||||
|
||||
class FeedViewModel(
|
||||
context: Context,
|
||||
private val repository: TrackingRepository
|
||||
) : BaseViewModel() {
|
||||
|
||||
@@ -34,7 +32,7 @@ class FeedViewModel(
|
||||
val onFeedCleared = SingleLiveEvent<Unit>()
|
||||
val content = combine(
|
||||
logList.filterNotNull().mapItems {
|
||||
it.toFeedItem(context.resources)
|
||||
it.toFeedItem()
|
||||
},
|
||||
hasNextPage
|
||||
) { list, isHasNextPage ->
|
||||
|
||||
@@ -12,6 +12,7 @@ import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.tracker.ui.model.FeedItem
|
||||
import org.koitharu.kotatsu.utils.ext.enqueueWith
|
||||
import org.koitharu.kotatsu.utils.ext.newImageRequest
|
||||
import org.koitharu.kotatsu.utils.ext.textAndVisible
|
||||
|
||||
fun feedItemAD(
|
||||
coil: ImageLoader,
|
||||
@@ -39,6 +40,11 @@ fun feedItemAD(
|
||||
binding.textViewTitle.text = item.title
|
||||
binding.badge.text = item.subtitle
|
||||
binding.textViewChapters.text = item.chapters
|
||||
binding.textViewTruncated.textAndVisible = if (item.truncated > 0) {
|
||||
getString(R.string._and_x_more, item.truncated)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
onViewRecycled {
|
||||
|
||||
@@ -9,5 +9,6 @@ data class FeedItem(
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val chapters: CharSequence,
|
||||
val manga: Manga
|
||||
val manga: Manga,
|
||||
val truncated: Int,
|
||||
) : ListModel
|
||||
@@ -1,19 +1,15 @@
|
||||
package org.koitharu.kotatsu.tracker.ui.model
|
||||
|
||||
import android.content.res.Resources
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.TrackingLogItem
|
||||
|
||||
fun TrackingLogItem.toFeedItem(resources: Resources): FeedItem {
|
||||
val chaptersString = if (chapters.size > MAX_CHAPTERS) {
|
||||
fun TrackingLogItem.toFeedItem(): FeedItem {
|
||||
val truncate = chapters.size > MAX_CHAPTERS
|
||||
val chaptersString = if (truncate) {
|
||||
chapters.joinToString(
|
||||
separator = "\n",
|
||||
limit = MAX_CHAPTERS - 1,
|
||||
truncated = resources.getString(
|
||||
R.string._and_x_more,
|
||||
chapters.size - MAX_CHAPTERS + 1
|
||||
)
|
||||
)
|
||||
truncated = "",
|
||||
).trimEnd()
|
||||
} else {
|
||||
chapters.joinToString("\n")
|
||||
}
|
||||
@@ -23,7 +19,8 @@ fun TrackingLogItem.toFeedItem(resources: Resources): FeedItem {
|
||||
title = manga.title,
|
||||
subtitle = chapters.size.toString(),
|
||||
chapters = chaptersString,
|
||||
manga = manga
|
||||
manga = manga,
|
||||
truncated = chapters.size - MAX_CHAPTERS + 1,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -183,7 +183,7 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) :
|
||||
setShortcutId(manga.id.toString())
|
||||
priority = NotificationCompat.PRIORITY_DEFAULT
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
builder.setSound(settings.notificationSound.toUriOrNull())
|
||||
builder.setSound(settings.notificationSound)
|
||||
var defaults = if (settings.notificationLight) {
|
||||
setLights(colorPrimary, 1000, 5000)
|
||||
NotificationCompat.DEFAULT_LIGHTS
|
||||
@@ -298,4 +298,4 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) :
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
package org.koitharu.kotatsu.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.os.StatFs
|
||||
import androidx.annotation.WorkerThread
|
||||
import okhttp3.Cache
|
||||
import okhttp3.CacheControl
|
||||
import org.koitharu.kotatsu.utils.ext.computeSize
|
||||
import org.koitharu.kotatsu.utils.ext.sub
|
||||
import java.io.File
|
||||
|
||||
object CacheUtils {
|
||||
|
||||
const val QUALIFIER_HTTP = "cache_http"
|
||||
|
||||
val CONTROL_DISABLED = CacheControl.Builder()
|
||||
.noStore()
|
||||
.build()
|
||||
|
||||
fun getCacheDirs(context: Context) = (context.externalCacheDirs + context.cacheDir)
|
||||
.filterNotNull()
|
||||
.distinctBy { it.absolutePath }
|
||||
|
||||
@WorkerThread
|
||||
fun computeCacheSize(context: Context, name: String) = getCacheDirs(context)
|
||||
.map { File(it, name) }
|
||||
.sumOf { x -> x.computeSize() }
|
||||
|
||||
@WorkerThread
|
||||
fun clearCache(context: Context, name: String) = getCacheDirs(context)
|
||||
.map { File(it, name) }
|
||||
.forEach { it.deleteRecursively() }
|
||||
|
||||
// FIXME need async implementation
|
||||
fun createHttpCache(context: Context): Cache {
|
||||
val directory = (context.externalCacheDir ?: context.cacheDir).sub("http")
|
||||
directory.mkdirs()
|
||||
val maxSize = calculateDiskCacheSize(directory) // TODO blocking call
|
||||
return Cache(directory, maxSize)
|
||||
}
|
||||
|
||||
private fun calculateDiskCacheSize(cacheDirectory: File): Long {
|
||||
return try {
|
||||
val cacheDir = StatFs(cacheDirectory.absolutePath)
|
||||
val size = DISK_CACHE_PERCENTAGE * cacheDir.blockCountLong * cacheDir.blockSizeLong
|
||||
return size.toLong().coerceIn(MIN_DISK_CACHE_SIZE, MAX_DISK_CACHE_SIZE)
|
||||
} catch (_: Exception) {
|
||||
MIN_DISK_CACHE_SIZE
|
||||
}
|
||||
}
|
||||
|
||||
private const val DISK_CACHE_PERCENTAGE = 0.02
|
||||
private const val MIN_DISK_CACHE_SIZE: Long = 10 * 1024 * 1024 // 10MB
|
||||
private const val MAX_DISK_CACHE_SIZE: Long = 250 * 1024 * 1024 // 250MB
|
||||
}
|
||||
@@ -6,14 +6,14 @@ import java.text.DecimalFormat
|
||||
import kotlin.math.log10
|
||||
import kotlin.math.pow
|
||||
|
||||
enum class FileSize(private val multiplier: Int) {
|
||||
|
||||
object FileSizeUtils {
|
||||
BYTES(1), KILOBYTES(1024), MEGABYTES(1024 * 1024);
|
||||
|
||||
fun mbToBytes(mb: Int) = 1024L * 1024L * mb
|
||||
fun convert(amount: Long, target: FileSize): Long = amount * multiplier / target.multiplier
|
||||
|
||||
fun kbToBytes(kb: Int) = 1024L * kb
|
||||
|
||||
fun formatBytes(context: Context, bytes: Long): String {
|
||||
fun format(context: Context, amount: Long): String {
|
||||
val bytes = amount * multiplier
|
||||
val units = context.getString(R.string.text_file_sizes).split('|')
|
||||
if (bytes <= 0) {
|
||||
return "0 ${units.first()}"
|
||||
@@ -23,10 +23,13 @@ object FileSizeUtils {
|
||||
append(
|
||||
DecimalFormat("#,##0.#").format(
|
||||
bytes / 1024.0.pow(digitGroups.toDouble())
|
||||
).toString()
|
||||
)
|
||||
)
|
||||
append(' ')
|
||||
append(units.getOrNull(digitGroups).orEmpty())
|
||||
val unit = units.getOrNull(digitGroups)
|
||||
if (unit != null) {
|
||||
append(' ')
|
||||
append(unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,15 +5,17 @@ import android.os.Build
|
||||
|
||||
object PendingIntentCompat {
|
||||
|
||||
@JvmField
|
||||
val FLAG_IMMUTABLE = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
||||
@JvmField
|
||||
val FLAG_MUTABLE = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
PendingIntent.FLAG_MUTABLE
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,4 +91,4 @@ sealed class Motion {
|
||||
anim.interpolator = DecelerateInterpolator()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
package org.koitharu.kotatsu.utils.delegates
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.fragment.app.Fragment
|
||||
import kotlin.properties.ReadOnlyProperty
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
class ParcelableArgumentDelegate<T : Parcelable>(private val name: String) :
|
||||
ReadOnlyProperty<Fragment, T> {
|
||||
|
||||
override fun getValue(thisRef: Fragment, property: KProperty<*>): T {
|
||||
return thisRef.requireArguments().getParcelable(name)!!
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package org.koitharu.kotatsu.utils.delegates
|
||||
|
||||
import androidx.fragment.app.Fragment
|
||||
import kotlin.properties.ReadOnlyProperty
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
class StringArgumentDelegate(private val name: String) : ReadOnlyProperty<Fragment, String?> {
|
||||
|
||||
override fun getValue(thisRef: Fragment, property: KProperty<*>): String? {
|
||||
return thisRef.arguments?.getString(name)
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package org.koitharu.kotatsu.utils.delegates.prefs
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import kotlin.properties.ReadWriteProperty
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
class BoolPreferenceDelegate(private val key: String, private val defaultValue: Boolean) :
|
||||
ReadWriteProperty<SharedPreferences, Boolean> {
|
||||
|
||||
override fun getValue(thisRef: SharedPreferences, property: KProperty<*>): Boolean {
|
||||
return thisRef.getBoolean(key, defaultValue)
|
||||
}
|
||||
|
||||
override fun setValue(thisRef: SharedPreferences, property: KProperty<*>, value: Boolean) {
|
||||
thisRef.edit {
|
||||
putBoolean(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package org.koitharu.kotatsu.utils.delegates.prefs
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import kotlin.properties.ReadWriteProperty
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
class EnumPreferenceDelegate<E : Enum<*>>(
|
||||
private val cls: Class<E>,
|
||||
private val key: String,
|
||||
private val defValue: E
|
||||
) : ReadWriteProperty<SharedPreferences, E> {
|
||||
|
||||
override fun getValue(thisRef: SharedPreferences, property: KProperty<*>): E {
|
||||
val name = thisRef.getString(key, null)
|
||||
if (name === null) {
|
||||
return defValue
|
||||
}
|
||||
return cls.enumConstants?.find { it.name == name } ?: defValue
|
||||
}
|
||||
|
||||
override fun setValue(thisRef: SharedPreferences, property: KProperty<*>, value: E) {
|
||||
thisRef.edit {
|
||||
putString(key, value.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package org.koitharu.kotatsu.utils.delegates.prefs
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import kotlin.properties.ReadWriteProperty
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
@Deprecated("")
|
||||
class IntEnumPreferenceDelegate<E : Enum<*>>(
|
||||
private val cls: Class<E>,
|
||||
private val key: String,
|
||||
private val defValue: E
|
||||
) : ReadWriteProperty<SharedPreferences, E> {
|
||||
|
||||
override fun getValue(thisRef: SharedPreferences, property: KProperty<*>): E {
|
||||
val ord = thisRef.getInt(key, -1)
|
||||
if (ord == -1) {
|
||||
return defValue
|
||||
}
|
||||
return cls.enumConstants?.getOrNull(ord) ?: defValue
|
||||
}
|
||||
|
||||
override fun setValue(thisRef: SharedPreferences, property: KProperty<*>, value: E) {
|
||||
thisRef.edit {
|
||||
putInt(key, value.ordinal)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package org.koitharu.kotatsu.utils.delegates.prefs
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import kotlin.properties.ReadWriteProperty
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
class IntPreferenceDelegate(private val key: String, private val defaultValue: Int) :
|
||||
ReadWriteProperty<SharedPreferences, Int> {
|
||||
|
||||
override fun getValue(thisRef: SharedPreferences, property: KProperty<*>): Int {
|
||||
return thisRef.getInt(key, defaultValue)
|
||||
}
|
||||
|
||||
override fun setValue(thisRef: SharedPreferences, property: KProperty<*>, value: Int) {
|
||||
thisRef.edit {
|
||||
putInt(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package org.koitharu.kotatsu.utils.delegates.prefs
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import kotlin.properties.ReadWriteProperty
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
class LongPreferenceDelegate(private val key: String, private val defaultValue: Long) :
|
||||
ReadWriteProperty<SharedPreferences, Long> {
|
||||
|
||||
override fun getValue(thisRef: SharedPreferences, property: KProperty<*>): Long {
|
||||
return thisRef.getLong(key, defaultValue)
|
||||
}
|
||||
|
||||
override fun setValue(thisRef: SharedPreferences, property: KProperty<*>, value: Long) {
|
||||
thisRef.edit {
|
||||
putLong(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package org.koitharu.kotatsu.utils.delegates.prefs
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import kotlin.properties.ReadWriteProperty
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
class NullableStringPreferenceDelegate(private val key: String) :
|
||||
ReadWriteProperty<SharedPreferences, String?> {
|
||||
|
||||
override fun getValue(thisRef: SharedPreferences, property: KProperty<*>): String? {
|
||||
return thisRef.getString(key, null)
|
||||
}
|
||||
|
||||
override fun setValue(thisRef: SharedPreferences, property: KProperty<*>, value: String?) {
|
||||
thisRef.edit {
|
||||
putString(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package org.koitharu.kotatsu.utils.delegates.prefs
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import kotlin.properties.ReadWriteProperty
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
class StringIntPreferenceDelegate(private val key: String, private val defValue: Int) :
|
||||
ReadWriteProperty<SharedPreferences, Int> {
|
||||
|
||||
override fun getValue(thisRef: SharedPreferences, property: KProperty<*>): Int {
|
||||
return thisRef.getString(key, defValue.toString())?.toIntOrNull() ?: defValue
|
||||
}
|
||||
|
||||
override fun setValue(thisRef: SharedPreferences, property: KProperty<*>, value: Int) {
|
||||
thisRef.edit {
|
||||
putString(key, value.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package org.koitharu.kotatsu.utils.delegates.prefs
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import kotlin.properties.ReadWriteProperty
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
class StringPreferenceDelegate(private val key: String, private val defValue: String) :
|
||||
ReadWriteProperty<SharedPreferences, String> {
|
||||
|
||||
override fun getValue(thisRef: SharedPreferences, property: KProperty<*>): String {
|
||||
return thisRef.getString(key, defValue) ?: defValue
|
||||
}
|
||||
|
||||
override fun setValue(thisRef: SharedPreferences, property: KProperty<*>, value: String) {
|
||||
thisRef.edit {
|
||||
putString(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package org.koitharu.kotatsu.utils.delegates.prefs
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import kotlin.properties.ReadWriteProperty
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
class StringSetPreferenceDelegate(
|
||||
private val key: String,
|
||||
private val defValue: Set<String> = emptySet()
|
||||
) :
|
||||
ReadWriteProperty<SharedPreferences, Set<String>> {
|
||||
|
||||
override fun getValue(thisRef: SharedPreferences, property: KProperty<*>): Set<String> {
|
||||
return thisRef.getStringSet(key, defValue) ?: defValue
|
||||
}
|
||||
|
||||
override fun setValue(thisRef: SharedPreferences, property: KProperty<*>, value: Set<String>) {
|
||||
thisRef.edit {
|
||||
putStringSet(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,18 +7,16 @@ import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.os.storage.StorageManager
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.core.database.getStringOrNull
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.R
|
||||
import java.io.File
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
@Suppress("NOTHING_TO_INLINE")
|
||||
@Deprecated("Useless", ReplaceWith("File(this, name)", "java.io.File"))
|
||||
inline fun File.sub(name: String) = File(this, name)
|
||||
|
||||
fun File.subdir(name: String) = File(this, name).also {
|
||||
if (!it.exists()) it.mkdirs()
|
||||
}
|
||||
@@ -29,22 +27,6 @@ fun ZipFile.readText(entry: ZipEntry) = getInputStream(entry).bufferedReader().u
|
||||
it.readText()
|
||||
}
|
||||
|
||||
fun File.computeSize(): Long = listFiles()?.sumOf { x ->
|
||||
if (x.isDirectory) {
|
||||
x.computeSize()
|
||||
} else {
|
||||
x.length()
|
||||
}
|
||||
} ?: 0L
|
||||
|
||||
inline fun File.findParent(predicate: (File) -> Boolean): File? {
|
||||
var current = this
|
||||
while (!predicate(current)) {
|
||||
current = current.parentFile ?: return null
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
fun File.getStorageName(context: Context): String = runCatching {
|
||||
val manager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
@@ -78,4 +60,18 @@ fun ContentResolver.resolveName(uri: Uri): String? {
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
suspend fun File.computeSize(): Long = runInterruptible(Dispatchers.IO) {
|
||||
computeSizeInternal(this)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun computeSizeInternal(file: File): Long {
|
||||
if (file.isDirectory) {
|
||||
val files = file.listFiles() ?: return 0L
|
||||
return files.sumOf { computeSizeInternal(it) }
|
||||
} else {
|
||||
return file.length()
|
||||
}
|
||||
}
|
||||
@@ -12,13 +12,13 @@ fun String.longHashCode(): Long {
|
||||
var h = 1125899906842597L
|
||||
val len: Int = this.length
|
||||
for (i in 0 until len) {
|
||||
h = 31 * h + this[i].toLong()
|
||||
h = 31 * h + this[i].code
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
fun String.removeSurrounding(vararg chars: Char): String {
|
||||
if (length == 0) {
|
||||
if (isEmpty()) {
|
||||
return this
|
||||
}
|
||||
for (c in chars) {
|
||||
@@ -224,4 +224,4 @@ inline fun <T> StringBuilder.appendAll(
|
||||
}
|
||||
append(transform(item))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ class ShelfConfigActivity : BaseActivity<ActivityCategoriesBinding>(),
|
||||
finishAfterTransition()
|
||||
return
|
||||
}
|
||||
config = AppWidgetConfig.getInstance(this, appWidgetId)
|
||||
config = AppWidgetConfig(this, appWidgetId)
|
||||
viewModel.checkedId = config.categoryId
|
||||
|
||||
viewModel.content.observe(this, this::onContentChanged)
|
||||
@@ -118,4 +118,4 @@ class ShelfConfigActivity : BaseActivity<ActivityCategoriesBinding>(),
|
||||
intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids)
|
||||
sendBroadcast(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ class ShelfListFactory(
|
||||
) : RemoteViewsService.RemoteViewsFactory {
|
||||
|
||||
private val dataSet = ArrayList<Manga>()
|
||||
private val config = AppWidgetConfig.getInstance(context, widgetId)
|
||||
private val config = AppWidgetConfig(context, widgetId)
|
||||
|
||||
override fun onCreate() {
|
||||
}
|
||||
@@ -73,4 +73,4 @@ class ShelfListFactory(
|
||||
override fun getViewTypeCount() = 1
|
||||
|
||||
override fun onDestroy() = Unit
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user