This commit is contained in:
tom5079
2020-12-23 17:09:48 +09:00
parent 521f3ad809
commit 3051d800bd
20 changed files with 576 additions and 746 deletions

View File

@@ -38,6 +38,7 @@ import com.google.firebase.analytics.FirebaseAnalytics
import com.google.firebase.analytics.ktx.analytics
import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.google.firebase.ktx.Firebase
import okhttp3.Dispatcher
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Response
@@ -48,6 +49,7 @@ import xyz.quaver.pupil.util.*
import xyz.quaver.setClient
import java.io.File
import java.util.*
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import kotlin.reflect.KClass
@@ -104,6 +106,7 @@ class Pupil : Application() {
interceptors[tag::class]?.invoke(chain) ?: chain.proceed(request)
}
.dispatcher(Dispatcher(Executors.newFixedThreadPool(4)))
try {
Preferences.get<String>("download_folder").also {

View File

@@ -18,12 +18,11 @@
package xyz.quaver.pupil.adapters
import android.content.Context
import android.net.Uri
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat
import androidx.core.view.updateLayoutParams
import androidx.recyclerview.widget.RecyclerView
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import com.facebook.drawee.view.SimpleDraweeView
@@ -31,21 +30,17 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import xyz.quaver.hitomi.GalleryInfo
import xyz.quaver.pupil.R
import xyz.quaver.pupil.databinding.ReaderItemBinding
import xyz.quaver.pupil.ui.ReaderActivity
import xyz.quaver.pupil.util.downloader.Cache
import xyz.quaver.pupil.util.downloader.Downloader
import kotlin.math.roundToInt
class ReaderAdapter(
private val activity: ReaderActivity,
private val galleryID: String
private val context: Context,
private val source: String,
private val itemID: String
) : RecyclerView.Adapter<ReaderAdapter.ViewHolder>() {
var reader: GalleryInfo? = null
var isFullScreen = false
var onItemClickListener : (() -> (Unit))? = null
inner class ViewHolder(private val binding: ReaderItemBinding) : RecyclerView.ViewHolder(binding.root) {
@@ -60,49 +55,36 @@ class ReaderAdapter(
binding.root.setOnClickListener {
onItemClickListener?.invoke()
}
binding.readerItemProgressbar.max = 100
}
fun bind(position: Int) {
if (cache == null)
cache = Cache.getInstance(itemView.context, galleryID)
if (!isFullScreen) {
binding.root.setBackgroundResource(R.drawable.reader_item_boundary)
binding.image.updateLayoutParams<ConstraintLayout.LayoutParams> {
height = 0
dimensionRatio =
"${reader!!.files[position].width}:${reader!!.files[position].height}"
}
} else {
binding.root.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT
binding.image.updateLayoutParams<ConstraintLayout.LayoutParams> {
height = ConstraintLayout.LayoutParams.MATCH_PARENT
dimensionRatio = null
}
binding.root.background = null
}
binding.readerIndex.text = (position+1).toString()
val image = cache!!.getImage(position)
val progress = activity.downloader?.progress?.get(galleryID)?.get(position)
val image = Cache.getInstance(context, source, itemID).getImage(position)?.uri
if (progress?.isInfinite() == true && image != null) {
binding.progressGroup.visibility = View.INVISIBLE
binding.image.showImage(image.uri)
} else {
binding.progressGroup.visibility = View.VISIBLE
binding.readerItemProgressbar.progress =
if (progress?.isInfinite() == true)
100
else
progress?.roundToInt() ?: 0
if (image != null)
binding.image.showImage(image)
else {
val progress = Downloader.getInstance(context).getProgress(source, itemID)?.get(position) ?: 0F
clear()
if (progress == Float.NEGATIVE_INFINITY)
with (binding.image) {
showImage(Uri.EMPTY)
CoroutineScope(Dispatchers.Main).launch {
delay(1000)
notifyItemChanged(position)
setOnClickListener {
if (Downloader.getInstance(context).getProgress(source, itemID)?.get(position) == Float.NEGATIVE_INFINITY)
Downloader.getInstance(context).retry(source, itemID)
}
}
else {
binding.readerItemProgressbar.progress = progress.roundToInt()
CoroutineScope(Dispatchers.Main).launch {
delay(1000)
notifyItemChanged(position)
}
}
}
}
@@ -123,12 +105,11 @@ class ReaderAdapter(
return ViewHolder(ReaderItemBinding.inflate(LayoutInflater.from(parent.context), parent, false))
}
private var cache: Cache? = null
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(position)
}
override fun getItemCount() = reader?.files?.size ?: 0
override fun getItemCount() = Downloader.getInstance(context).getProgress(source, itemID)?.size ?: 0
override fun onViewRecycled(holder: ViewHolder) {
holder.clear()

View File

@@ -18,6 +18,7 @@
package xyz.quaver.pupil.adapters
import android.annotation.SuppressLint
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
@@ -36,41 +37,34 @@ import com.facebook.imagepipeline.image.ImageInfo
import kotlinx.coroutines.*
import xyz.quaver.pupil.R
import xyz.quaver.pupil.databinding.SearchResultItemBinding
import xyz.quaver.pupil.sources.SearchResult
import xyz.quaver.pupil.sources.ItemInfo
import xyz.quaver.pupil.types.Tag
import xyz.quaver.pupil.ui.view.ProgressCardView
import xyz.quaver.pupil.util.downloader.Cache
import xyz.quaver.pupil.util.downloader.DownloadManager
import xyz.quaver.pupil.util.downloader.Downloader
import kotlin.time.ExperimentalTime
class SearchResultsAdapter(private val results: List<SearchResult>) : RecyclerSwipeAdapter<SearchResultsAdapter.ViewHolder>(), SwipeAdapterInterface {
class SearchResultsAdapter(private val results: List<ItemInfo>) : RecyclerSwipeAdapter<SearchResultsAdapter.ViewHolder>(), SwipeAdapterInterface {
var onChipClickedHandler: ((Tag) -> Unit)? = null
var onDownloadClickedHandler: ((String) -> Unit)? = null
var onDeleteClickedHandler: ((String) -> Unit)? = null
var onDownloadClickedHandler: ((source: String, itemID: String) -> Unit)? = null
var onDeleteClickedHandler: ((source: String, itemID: String) -> Unit)? = null
// TODO: migrate to viewBinding
val progressUpdateScope = CoroutineScope(Dispatchers.Main + Job())
inner class ViewHolder(private val binding: SearchResultItemBinding) : RecyclerView.ViewHolder(binding.root) {
var source: String = ""
var itemID: String = ""
private var bindJob: Job? = null
init {
progressUpdateScope.launch {
while (true) {
updateProgress()
delay(1000)
}
}
binding.root.binding.download.setOnClickListener {
onDownloadClickedHandler?.invoke(itemID)
onDownloadClickedHandler?.invoke(source, itemID)
}
binding.root.binding.delete.setOnClickListener {
onDeleteClickedHandler?.invoke(itemID)
onDeleteClickedHandler?.invoke(source, itemID)
}
binding.idView.setOnClickListener {
@@ -85,7 +79,7 @@ class SearchResultsAdapter(private val results: List<SearchResult>) : RecyclerSw
mItemManger.closeAllExcept(layout)
binding.root.binding.download.text =
if (DownloadManager.getInstance(itemView.context).isDownloading(itemID))
if (Downloader.getInstance(itemView.context).isDownloading(source, itemID))
itemView.context.getString(android.R.string.cancel)
else
itemView.context.getString(R.string.main_download)
@@ -99,33 +93,16 @@ class SearchResultsAdapter(private val results: List<SearchResult>) : RecyclerSw
})
binding.tagGroup.onClickListener = onChipClickedHandler
}
private fun updateProgress() = CoroutineScope(Dispatchers.Main).launch {
with (itemView as ProgressCardView) {
val imageList = Cache.getInstance(context, itemID).metadata.imageList
if (imageList == null) {
max = 0
return@with
CoroutineScope(Dispatchers.Main).launch {
while (true) {
updateProgress()
delay(1000)
}
progress = imageList.count { it != null }
max = imageList.size
type = if (!imageList.contains(null)) {
val downloadManager = DownloadManager.getInstance(context)
if (downloadManager.getDownloadFolder(itemID) == null)
ProgressCardView.Type.CACHE
else
ProgressCardView.Type.DOWNLOAD
} else
ProgressCardView.Type.LOADING
}
}
val controllerListener = object: BaseControllerListener<ImageInfo>() {
private val controllerListener = object: BaseControllerListener<ImageInfo>() {
override fun onIntermediateImageSet(id: String?, imageInfo: ImageInfo?) {
imageInfo?.let {
binding.thumbnail.aspectRatio = it.width / it.height.toFloat()
@@ -138,22 +115,40 @@ class SearchResultsAdapter(private val results: List<SearchResult>) : RecyclerSw
}
}
}
fun bind(result: SearchResult) {
bindJob?.cancel()
private fun updateProgress() {
val cache = Cache.getInstance(itemView.context, source, itemID)
binding.root.max = cache.metadata.imageList?.size ?: 0
binding.root.progress = cache.metadata.imageList?.count { it != null } ?: 0
binding.root.type = if (cache.metadata.imageList?.all { it != null } == true) { // Download completed
if (DownloadManager.getInstance(itemView.context).getDownloadFolder(source, itemID) != null)
ProgressCardView.Type.DOWNLOAD
else
ProgressCardView.Type.CACHE
} else
ProgressCardView.Type.LOADING
}
@SuppressLint("SetTextI18n")
fun bind(result: ItemInfo) {
source = result.source
itemID = result.id
binding.root.progress = 0
binding.thumbnail.controller = Fresco.newDraweeControllerBuilder()
.setUri(result.thumbnail)
.setOldController(binding.thumbnail.controller)
.setControllerListener(controllerListener)
.build()
updateProgress()
binding.title.text = result.title
binding.idView.text = result.id
binding.artist.visibility = if (result.artists.isEmpty()) View.GONE else View.VISIBLE
binding.artist.text = result.artists
with (binding.tagGroup) {
@@ -164,60 +159,44 @@ class SearchResultsAdapter(private val results: List<SearchResult>) : RecyclerSw
refresh()
}
binding.pagecount.text = "-"
val extraType = listOf(
ItemInfo.ExtraType.SERIES,
ItemInfo.ExtraType.TYPE,
ItemInfo.ExtraType.LANGUAGE
)
bindJob = MainScope().launch {
val extra = result.extra.mapValues {
async(Dispatchers.IO) {
kotlin.runCatching { withTimeout(1000) {
it.value.invoke()
} }.getOrNull()
}
CoroutineScope(Dispatchers.Main).launch {
result.extra[ItemInfo.ExtraType.GROUP]?.await()?.let {
if (it.isNotEmpty())
binding.artist.text = "${result.artists} ($it)"
}
}
launch {
val extraType = listOf(
SearchResult.ExtraType.SERIES,
SearchResult.ExtraType.TYPE,
SearchResult.ExtraType.LANGUAGE
)
binding.extra.text = extra.entries.filter { it.key in extraType }.fold(StringBuilder()) { res, entry ->
entry.value.await().let {
if (!it.isNullOrEmpty()) {
CoroutineScope(Dispatchers.Main).launch {
binding.extra.text =
result.extra.entries.filter { it.key in extraType && it.value.await() != null }.fold(StringBuilder()) { res, entry ->
entry.value.await()?.let {
if (it.isNotEmpty()) {
res.append(
itemView.context.getString(
SearchResult.extraTypeMap[entry.key] ?: error(""),
it
ItemInfo.extraTypeMap[entry.key] ?: error(""),
entry.value.await()
)
)
res.append('\n')
}
res
}
res
}
}
}
launch {
extra[SearchResult.ExtraType.PAGECOUNT]?.await()?.let {
binding.pagecount.text =
itemView.context.getString(
SearchResult.extraTypeMap[SearchResult.ExtraType.PAGECOUNT] ?: error(""),
it
)
}
}
launch {
extra[SearchResult.ExtraType.GROUP]?.await().let {
if (!it.isNullOrEmpty())
binding.artist.text = itemView.context.getString(
R.string.galleryblock_artist_with_group,
result.artists,
it
)
}
}
CoroutineScope(Dispatchers.Main).launch {
binding.pagecount.text = result.extra[ItemInfo.ExtraType.PAGECOUNT]?.let {
itemView.context.getString(
ItemInfo.extraTypeMap[ItemInfo.ExtraType.PAGECOUNT] ?: error(""),
it.await()
)
} ?: "-"
}
}
}

View File

@@ -50,6 +50,7 @@ import kotlin.math.ceil
import kotlin.math.log10
private typealias ProgressListener = (DownloadService.Tag, Long, Long, Boolean) -> Unit
@Deprecated(message = "Use xyz.quaver.util.downloader.Downloader")
class DownloadService : Service() {
data class Tag(val galleryID: String, val index: Int, val startId: Int? = null)
@@ -119,11 +120,6 @@ class DownloadService : Service() {
notification
.setProgress(max, progress, false)
.setContentText("$progress/$max")
if (DownloadManager.getInstance(this).getDownloadFolder(galleryID) != null || galleryID == priority)
notification.let { notificationManager.notify(galleryID.hashCode(), it.build()) }
else
notificationManager.cancel(galleryID.hashCode())
}
//endregion
@@ -184,16 +180,16 @@ class DownloadService : Service() {
//region Downloader
/**
* KEY
* primary galleryID
* secondary index
* PRIMARY VALUE
* MutableList -> Download in progress
* null -> Loading / Gallery doesn't exist
* SECONDARY VALUE
* 0 <= value < 100 -> Download in progress
* Float.POSITIVE_INFINITY -> Download completed
*/
* KEY
* primary galleryID
* secondary index
* PRIMARY VALUE
* MutableList -> Download in progress
* null -> Loading / Gallery doesn't exist
* SECONDARY VALUE
* 0 <= value < 100 -> Download in progress
* Float.POSITIVE_INFINITY -> Download completed
*/
val progress = ConcurrentHashMap<String, MutableList<Float>>()
var priority = ""
@@ -214,34 +210,6 @@ class DownloadService : Service() {
}
override fun onResponse(call: Call, response: Response) {
val (galleryID, index, startId) = call.request().tag() as Tag
val ext = call.request().url().encodedPath().split('.').last()
kotlin.runCatching {
val image = response.also { if (it.code() != 200) throw IOException() }.body()?.use { it.bytes() } ?: throw Exception()
val padding = ceil(progress[galleryID]?.size?.let { log10(it.toFloat()) } ?: 0F).toInt()
CoroutineScope(Dispatchers.IO).launch {
kotlin.runCatching {
Cache.getInstance(this@DownloadService, galleryID).putImage(index, "${index.toString().padStart(padding, '0')}.$ext", image)
}.onSuccess {
progress[galleryID]?.set(index, Float.POSITIVE_INFINITY)
notify(galleryID)
if (isCompleted(galleryID)) {
if (DownloadManager.getInstance(this@DownloadService)
.getDownloadFolder(galleryID) != null)
Cache.getInstance(this@DownloadService, galleryID).moveToDownload()
startId?.let { stopSelf(it) }
}
}.onFailure {
it.printStackTrace()
cancel(galleryID)
download(galleryID)
}
}
}
}
}
@@ -288,74 +256,11 @@ class DownloadService : Service() {
}
fun delete(galleryID: String, startId: Int? = null) = CoroutineScope(Dispatchers.IO).launch {
cancel(galleryID)
DownloadManager.getInstance(this@DownloadService).deleteDownloadFolder(galleryID)
Cache.delete(this@DownloadService, galleryID)
startId?.let { stopSelf(it) }
}
fun download(galleryID: String, priority: Boolean = false, startId: Int? = null): Job = CoroutineScope(Dispatchers.IO).launch {
if (DownloadManager.getInstance(this@DownloadService).isDownloading(galleryID))
return@launch
cleanCache(this@DownloadService)
val cache = Cache.getInstance(this@DownloadService, galleryID)
initNotification(galleryID)
val reader = cache.getReader()
// Gallery doesn't exist
if (reader == null) {
delete(galleryID)
progress[galleryID] = mutableListOf()
return@launch
}
histories.add(galleryID)
progress[galleryID] = MutableList(reader.files.size) { 0F }
cache.metadata.imageList?.let {
it.forEachIndexed { index, image ->
progress[galleryID]?.set(index, if (image != null) Float.POSITIVE_INFINITY else 0F)
}
}
if (isCompleted(galleryID)) {
if (DownloadManager.getInstance(this@DownloadService)
.getDownloadFolder(galleryID) != null )
Cache.getInstance(this@DownloadService, galleryID).moveToDownload()
notificationManager.cancel(galleryID.hashCode())
startId?.let { stopSelf(it) }
return@launch
}
notification[galleryID]?.setContentTitle(reader.title?.ellipsize(30))
notify(galleryID)
val queued = mutableSetOf<String>()
if (priority) {
client.dispatcher().queuedCalls().forEach {
val queuedID = (it.request().tag() as? Tag)?.galleryID ?: return@forEach
if (queued.add(queuedID))
cancel(queuedID)
}
}
reader.requestBuilders.forEachIndexed { index, it ->
if (progress[galleryID]?.get(index)?.isInfinite() == false) {
val request = it.tag(Tag(galleryID, index, startId)).build()
client.newCall(request).enqueue(callback)
}
}
queued.forEach { download(it) }
}
//endregion

View File

@@ -21,20 +21,27 @@ package xyz.quaver.pupil.sources
import android.content.Context
import android.graphics.drawable.Drawable
import androidx.core.content.ContextCompat
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.parcelize.Parcelize
import okhttp3.Request
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import xyz.quaver.floatingsearchview.databinding.SearchSuggestionItemBinding
import xyz.quaver.floatingsearchview.suggestions.model.SearchSuggestion
import xyz.quaver.pupil.R
data class SearchResult(
@Serializable(with = ItemInfo.SearchResultSerializer::class)
data class ItemInfo(
val source: String,
val id: String,
val title: String,
val thumbnail: String,
val artists: String,
val extra: Map<ExtraType, suspend () -> String>,
val tags: List<String>
val tags: List<String>,
val extra: Map<ExtraType, Deferred<String?>> = emptyMap()
) {
enum class ExtraType {
GROUP,
@@ -45,6 +52,48 @@ data class SearchResult(
PAGECOUNT
}
@Serializable
@SerialName("SearchResult")
data class ItemInfoSurrogate(
val source: String,
val id: String,
val title: String,
val thumbnail: String,
val artists: String,
val tags: List<String>,
val extra: Map<ExtraType, String?> = emptyMap()
)
object SearchResultSerializer : KSerializer<ItemInfo> {
override val descriptor = ItemInfoSurrogate.serializer().descriptor
override fun serialize(encoder: Encoder, value: ItemInfo) {
val surrogate = ItemInfoSurrogate(
value.source,
value.id,
value.title,
value.thumbnail,
value.artists,
value.tags,
value.extra.mapValues { runBlocking { it.value.await() } }
)
encoder.encodeSerializableValue(ItemInfoSurrogate.serializer(), surrogate)
}
override fun deserialize(decoder: Decoder): ItemInfo {
val surrogate = decoder.decodeSerializableValue(ItemInfoSurrogate.serializer())
return ItemInfo(
surrogate.source,
surrogate.id,
surrogate.title,
surrogate.thumbnail,
surrogate.artists,
surrogate.tags,
surrogate.extra.mapValues { CoroutineScope(Dispatchers.Unconfined).async { it.value } }
)
}
}
companion object {
val extraTypeMap = mapOf(
ExtraType.SERIES to R.string.galleryblock_series,
@@ -67,9 +116,14 @@ abstract class Source<Query_SortMode: Enum<Query_SortMode>, Suggestion: SearchSu
abstract val iconResID: Int
abstract val availableSortMode: Array<Query_SortMode>
abstract suspend fun search(query: String, range: IntRange, sortMode: Enum<*>) : Pair<Channel<SearchResult>, Int>
abstract suspend fun search(query: String, range: IntRange, sortMode: Enum<*>) : Pair<Channel<ItemInfo>, Int>
abstract suspend fun suggestion(query: String) : List<Suggestion>
abstract suspend fun images(id: String) : List<Request.Builder>
abstract suspend fun images(id: String) : List<String>
/* abstract suspend */ fun info(id: String)/* : ItemInfo */{}
open fun getHeadersForImage(id: String, url: String): Map<String, String> {
return emptyMap()
}
open fun onSuggestionBind(binding: SearchSuggestionItemBinding, item: Suggestion) {
binding.leftIcon.setImageResource(R.drawable.tag)

View File

@@ -18,18 +18,18 @@
package xyz.quaver.pupil.sources
import android.util.Log
import android.view.LayoutInflater
import android.widget.TextView
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import okhttp3.Request
import xyz.quaver.floatingsearchview.databinding.SearchSuggestionItemBinding
import xyz.quaver.floatingsearchview.suggestions.model.SearchSuggestion
import xyz.quaver.hitomi.*
import xyz.quaver.pupil.R
import xyz.quaver.pupil.sources.SearchResult.ExtraType
import xyz.quaver.pupil.sources.ItemInfo.ExtraType
import xyz.quaver.pupil.util.translations
import xyz.quaver.pupil.util.wordCapitalize
import kotlin.math.max
@@ -63,7 +63,7 @@ class Hitomi : Source<Hitomi.SortMode, Hitomi.TagSuggestion>() {
var cachedSortMode: SortMode? = null
val cache = mutableListOf<Int>()
override suspend fun search(query: String, range: IntRange, sortMode: Enum<*>): Pair<Channel<SearchResult>, Int> {
override suspend fun search(query: String, range: IntRange, sortMode: Enum<*>): Pair<Channel<ItemInfo>, Int> {
if (cachedQuery != query || cachedSortMode != sortMode || cache.isEmpty()) {
cachedQuery = null
cache.clear()
@@ -75,7 +75,7 @@ class Hitomi : Source<Hitomi.SortMode, Hitomi.TagSuggestion>() {
cachedQuery = query
}
val channel = Channel<SearchResult>()
val channel = Channel<ItemInfo>()
val sanitizedRange = max(0, range.first) .. min(range.last, cache.size-1)
CoroutineScope(Dispatchers.IO).launch {
@@ -84,12 +84,7 @@ class Hitomi : Source<Hitomi.SortMode, Hitomi.TagSuggestion>() {
getGalleryBlock(it)
}
}.forEach {
kotlin.runCatching {
yield()
channel.send(transform(it.await()))
}.onFailure {
channel.close()
}
channel.send(transform(name, it.await()))
}
channel.close()
@@ -98,19 +93,23 @@ class Hitomi : Source<Hitomi.SortMode, Hitomi.TagSuggestion>() {
return Pair(channel, cache.size)
}
override suspend fun images(id: String): List<Request.Builder> {
override suspend fun images(id: String): List<String> {
val galleryID = id.toInt()
val reader = getGalleryInfo(galleryID)
return reader.files.map {
Request.Builder()
.url(imageUrlFromImage(galleryID, it, true))
.header("Referer", getReferer(galleryID))
imageUrlFromImage(galleryID, it, true)
}
}
override suspend fun suggestion(query: String) : List<TagSuggestion> {
override fun getHeadersForImage(id: String, url: String): Map<String, String> {
return mapOf(
"Referer" to getReferer(id.toInt())
)
}
override suspend fun suggestion(query: String) : List<Hitomi.TagSuggestion> {
return getSuggestionsForQuery(query.takeLastWhile { !it.isWhitespace() }).map {
TagSuggestion(it)
}
@@ -189,20 +188,25 @@ class Hitomi : Source<Hitomi.SortMode, Hitomi.TagSuggestion>() {
"japanese" to "日本語"
)
fun transform(galleryBlock: GalleryBlock): SearchResult =
SearchResult(
fun transform(name: String, galleryBlock: GalleryBlock) =
ItemInfo(
name,
galleryBlock.id.toString(),
galleryBlock.title,
galleryBlock.thumbnails.first(),
galleryBlock.artists.joinToString { it.wordCapitalize() },
galleryBlock.relatedTags,
mapOf(
ExtraType.GROUP to { getGallery(galleryBlock.id).groups.joinToString { it.wordCapitalize() } },
ExtraType.SERIES to { galleryBlock.series.joinToString { it.wordCapitalize() } },
ExtraType.TYPE to { galleryBlock.type.wordCapitalize() },
ExtraType.LANGUAGE to { languageMap[galleryBlock.language] ?: galleryBlock.language },
ExtraType.PAGECOUNT to { getGalleryInfo(galleryBlock.id).files.size.toString() }
),
galleryBlock.relatedTags
ExtraType.GROUP to CoroutineScope(Dispatchers.IO).async { kotlin.runCatching {
getGallery(galleryBlock.id).groups.joinToString { it.wordCapitalize() }
}.getOrDefault("") },
ExtraType.SERIES to CoroutineScope(Dispatchers.Unconfined).async { galleryBlock.series.joinToString { it.wordCapitalize() } },
ExtraType.TYPE to CoroutineScope(Dispatchers.Unconfined).async { galleryBlock.type.wordCapitalize() },
ExtraType.LANGUAGE to CoroutineScope(Dispatchers.Unconfined).async { languageMap[galleryBlock.language] ?: galleryBlock.language },
ExtraType.PAGECOUNT to CoroutineScope(Dispatchers.IO).async { kotlin.runCatching {
getGalleryInfo(galleryBlock.id).files.size.toString()
}.getOrNull() }
)
)
}

View File

@@ -37,8 +37,8 @@ class Hiyobi : Source<DefaultSortMode, DefaultSearchSuggestion>() {
override val iconResID: Int = R.drawable.ic_hiyobi
override val availableSortMode: Array<DefaultSortMode> = DefaultSortMode.values()
override suspend fun search(query: String, range: IntRange, sortMode: Enum<*>): Pair<Channel<SearchResult>, Int> {
val channel = Channel<SearchResult>()
override suspend fun search(query: String, range: IntRange, sortMode: Enum<*>): Pair<Channel<ItemInfo>, Int> {
val channel = Channel<ItemInfo>()
val (results, total) = if (query.isEmpty())
list(range)
@@ -47,7 +47,7 @@ class Hiyobi : Source<DefaultSortMode, DefaultSearchSuggestion>() {
CoroutineScope(Dispatchers.Unconfined).launch {
results.forEach {
channel.send(transform(it))
channel.send(transform(name, it))
}
channel.close()
@@ -72,10 +72,9 @@ class Hiyobi : Source<DefaultSortMode, DefaultSearchSuggestion>() {
return result.map { DefaultSearchSuggestion(it) }
}
override suspend fun images(id: String): List<Request.Builder> {
override suspend fun images(id: String): List<String> {
return createImgList(id, getGalleryInfo(id), true).map {
Request.Builder()
.url(it.path)
it.path
}
}
@@ -115,21 +114,23 @@ class Hiyobi : Source<DefaultSortMode, DefaultSearchSuggestion>() {
_allTags = it
} else _allTags!!
fun transform(galleryBlock: GalleryBlock): SearchResult =
SearchResult(
suspend fun transform(name: String, galleryBlock: GalleryBlock): ItemInfo = withContext(Dispatchers.IO) {
ItemInfo(
name,
galleryBlock.id,
galleryBlock.title,
"https://cdn.$hiyobi/tn/${galleryBlock.id}.jpg",
galleryBlock.artists.joinToString { it.value.wordCapitalize() },
galleryBlock.tags.map { it.value },
mapOf(
SearchResult.ExtraType.CHARACTER to { galleryBlock.characters.joinToString { it.value.wordCapitalize() } },
SearchResult.ExtraType.SERIES to { galleryBlock.parodys.joinToString { it.value.wordCapitalize() } },
SearchResult.ExtraType.TYPE to { galleryBlock.type.name.replace('_', ' ').wordCapitalize() },
SearchResult.ExtraType.PAGECOUNT to { getGalleryInfo(galleryBlock.id).files.size.toString() },
SearchResult.ExtraType.GROUP to { galleryBlock.groups.joinToString { it.value.wordCapitalize() } }
),
galleryBlock.tags.map { it.value }
ItemInfo.ExtraType.CHARACTER to async { galleryBlock.characters.joinToString { it.value.wordCapitalize() } },
ItemInfo.ExtraType.SERIES to async { galleryBlock.parodys.joinToString { it.value.wordCapitalize() } },
ItemInfo.ExtraType.TYPE to async { galleryBlock.type.name.replace('_', ' ').wordCapitalize() },
ItemInfo.ExtraType.PAGECOUNT to async { getGalleryInfo(galleryBlock.id).files.size.toString() },
ItemInfo.ExtraType.GROUP to async { galleryBlock.groups.joinToString { it.value.wordCapitalize() } }
)
)
}
}
}

View File

@@ -51,7 +51,7 @@ import xyz.quaver.pupil.*
import xyz.quaver.pupil.adapters.SearchResultsAdapter
import xyz.quaver.pupil.databinding.MainActivityBinding
import xyz.quaver.pupil.services.DownloadService
import xyz.quaver.pupil.sources.SearchResult
import xyz.quaver.pupil.sources.ItemInfo
import xyz.quaver.pupil.sources.Source
import xyz.quaver.pupil.sources.sourceIcons
import xyz.quaver.pupil.sources.sources
@@ -62,7 +62,9 @@ import xyz.quaver.pupil.ui.dialog.SourceSelectDialog
import xyz.quaver.pupil.ui.view.ProgressCardView
import xyz.quaver.pupil.ui.view.SwipePageTurnView
import xyz.quaver.pupil.util.*
import xyz.quaver.pupil.util.downloader.Cache
import xyz.quaver.pupil.util.downloader.DownloadManager
import xyz.quaver.pupil.util.downloader.Downloader
import java.util.regex.Pattern
import kotlin.math.*
import kotlin.random.Random
@@ -71,7 +73,7 @@ class MainActivity :
BaseActivity(),
NavigationView.OnNavigationItemSelectedListener
{
private val searchResults = mutableListOf<SearchResult>()
private val searchResults = mutableListOf<ItemInfo>()
private var query = ""
set(value) {
@@ -86,7 +88,7 @@ class MainActivity :
private lateinit var source: Source<*, SearchSuggestion>
private lateinit var sortMode: Enum<*>
private var searchJob: Deferred<Pair<Channel<SearchResult>, Int>>? = null
private var searchJob: Deferred<Pair<Channel<ItemInfo>, Int>>? = null
private var totalItems = 0
private var currentPage = 1
@@ -221,7 +223,7 @@ class MainActivity :
with (binding.contents.cancelFab) {
setImageResource(R.drawable.cancel)
setOnClickListener {
DownloadService.cancel(this@MainActivity)
Downloader.getInstance(context).cancel()
}
}
@@ -351,22 +353,23 @@ class MainActivity :
query()
}
onDownloadClickedHandler = { id ->
if (DownloadManager.getInstance(context).isDownloading(id)) { //download in progress
DownloadService.cancel(this@MainActivity, id)
onDownloadClickedHandler = { source, itemID ->
if (Downloader.getInstance(context).isDownloading(source, itemID)) { //download in progress
Downloader.getInstance(context).cancel(source, itemID)
}
else {
DownloadManager.getInstance(context).addDownloadFolder(id)
DownloadService.download(this@MainActivity, id)
DownloadManager.getInstance(context).addDownloadFolder(source, itemID)
Downloader.getInstance(context).download(source, itemID)
}
closeAllItems()
}
onDeleteClickedHandler = { id ->
DownloadService.delete(this@MainActivity, id)
onDeleteClickedHandler = { source, itemID ->
Downloader.getInstance(context).cancel(source, itemID)
Cache.delete(source, itemID)
histories.remove(id)
histories.remove(itemID)
closeAllItems()
}
@@ -376,8 +379,10 @@ class MainActivity :
if (v !is ProgressCardView)
return@listener
val intent = Intent(this@MainActivity, ReaderActivity::class.java)
intent.putExtra("galleryID", searchResults[position].id)
val intent = Intent(this@MainActivity, ReaderActivity::class.java).apply {
putExtra("source", source.name)
putExtra("id", searchResults[position].id)
}
//TODO: Maybe sprinkling some transitions will be nice :D
startActivity(intent)

View File

@@ -18,28 +18,18 @@
package xyz.quaver.pupil.ui
import android.content.ComponentName
import android.content.Intent
import android.content.ServiceConnection
import android.graphics.drawable.Animatable
import android.graphics.drawable.Drawable
import android.os.Bundle
import android.os.IBinder
import android.view.*
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.PagerSnapHelper
import androidx.recyclerview.widget.RecyclerView
import androidx.vectordrawable.graphics.drawable.Animatable2Compat
import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
import com.google.android.material.snackbar.Snackbar
import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.qtalk.recyclerviewfastscroller.RecyclerViewFastScroller
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import xyz.quaver.pupil.R
import xyz.quaver.pupil.adapters.ReaderAdapter
import xyz.quaver.pupil.databinding.NumberpickerDialogBinding
@@ -47,12 +37,13 @@ import xyz.quaver.pupil.databinding.ReaderActivityBinding
import xyz.quaver.pupil.favorites
import xyz.quaver.pupil.services.DownloadService
import xyz.quaver.pupil.util.Preferences
import xyz.quaver.pupil.util.downloader.Cache
import xyz.quaver.pupil.util.downloader.DownloadManager
import xyz.quaver.pupil.util.downloader.Downloader
class ReaderActivity : BaseActivity() {
private var galleryID = ""
private var source = ""
private var itemID = ""
private var currentPage = 0
private var isScroll = true
@@ -60,24 +51,7 @@ class ReaderActivity : BaseActivity() {
set(value) {
field = value
(binding.recyclerview.adapter as ReaderAdapter).isFullScreen = value
}
private lateinit var cache: Cache
var downloader: DownloadService? = null
private val conn = object: ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
downloader = (service as DownloadService.Binder).service.also {
it.priority = ""
if (!it.progress.containsKey(galleryID))
DownloadService.download(this@ReaderActivity, galleryID, true)
}
}
override fun onServiceDisconnected(name: ComponentName?) {
downloader = null
}
//(binding.recyclerview.adapter as ReaderAdapter).isFullScreen = value
}
private val snapHelper = PagerSnapHelper()
@@ -94,15 +68,36 @@ class ReaderActivity : BaseActivity() {
supportActionBar?.setDisplayHomeAsUpEnabled(false)
handleIntent(intent)
cache = Cache.getInstance(this, galleryID)
FirebaseCrashlytics.getInstance().setCustomKey("GalleryID", galleryID)
FirebaseCrashlytics.getInstance().setCustomKey("GalleryID", itemID)
if (galleryID.isEmpty()) {
if (itemID.isEmpty()) {
onBackPressed()
return
}
initDownloadListener()
with (Downloader.getInstance(this)) {
onImageListLoadedCallback = {
runOnUiThread {
binding.recyclerview.adapter?.notifyDataSetChanged()
}
}
download(source, itemID)
}
binding.recyclerview.adapter = ReaderAdapter(this, source, itemID).apply {
onItemClickListener = {
if (isScroll) {
isScroll = false
isFullscreen = true
scrollMode(false)
fullscreen(true)
} else {
(binding.recyclerview.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(currentPage, 0) //Moves to next page because currentPage is 1-based indexing
}
}
}
initView()
}
@@ -116,7 +111,8 @@ class ReaderActivity : BaseActivity() {
val uri = intent.data
val lastPathSegment = uri?.lastPathSegment
if (uri != null && lastPathSegment != null) {
galleryID = when (uri.host) {
source = uri.host ?: ""
itemID = when (uri.host) {
"hitomi.la" ->
Regex("([0-9]+).html").find(lastPathSegment)?.groupValues?.get(1) ?: ""
"hiyobi.me" -> lastPathSegment
@@ -125,7 +121,8 @@ class ReaderActivity : BaseActivity() {
}
}
} else {
galleryID = intent.getStringExtra("galleryID") ?: ""
source = intent.getStringExtra("source") ?: ""
itemID = intent.getStringExtra("id") ?: ""
}
}
@@ -135,7 +132,7 @@ class ReaderActivity : BaseActivity() {
with (menu?.findItem(R.id.reader_menu_favorite)) {
this ?: return@with
if (favorites.contains(galleryID))
if (favorites.contains(itemID))
(icon as Animatable).start()
}
@@ -151,7 +148,7 @@ class ReaderActivity : BaseActivity() {
with (binding.numberPicker) {
minValue = 1
maxValue = cache.metadata.reader?.files?.size ?: 0
maxValue = this@ReaderActivity.binding.recyclerview.adapter?.itemCount ?: 0
value = currentPage
}
val dialog = AlertDialog.Builder(this).apply {
@@ -165,7 +162,7 @@ class ReaderActivity : BaseActivity() {
dialog.show()
}
R.id.reader_menu_favorite -> {
val id = galleryID
val id = itemID
val favorite = menu?.findItem(R.id.reader_menu_favorite) ?: return true
if (favorites.contains(id)) {
@@ -181,30 +178,6 @@ class ReaderActivity : BaseActivity() {
return true
}
override fun onResume() {
super.onResume()
bindService(Intent(this, DownloadService::class.java), conn, BIND_AUTO_CREATE)
}
override fun onPause() {
super.onPause()
if (downloader != null)
unbindService(conn)
downloader?.priority = galleryID
}
override fun onDestroy() {
super.onDestroy()
update = false
if (!DownloadManager.getInstance(this).isDownloading(galleryID))
DownloadService.cancel(this, galleryID)
}
override fun onBackPressed() {
if (isScroll and !isFullscreen)
super.onBackPressed()
@@ -237,81 +210,8 @@ class ReaderActivity : BaseActivity() {
}
}
private var update = true
private fun initDownloadListener() {
CoroutineScope(Dispatchers.Main).launch {
while (update) {
delay(1000)
val downloader = downloader ?: continue
if (!downloader.progress.containsKey(galleryID)) //loading
continue
if (downloader.progress[galleryID]?.isEmpty() == true) { //Gallery not found
update = false
Snackbar
.make(binding.root, R.string.reader_failed_to_find_gallery, Snackbar.LENGTH_INDEFINITE)
.show()
return@launch
}
binding.downloadProgressbar.max = binding.recyclerview.adapter?.itemCount ?: 0
binding.downloadProgressbar.progress =
downloader.progress[galleryID]?.count { it.isInfinite() } ?: 0
if (title == getString(R.string.reader_loading)) {
val reader = cache.metadata.reader
if (reader != null) {
with (binding.recyclerview.adapter as ReaderAdapter) {
this.reader = reader
notifyDataSetChanged()
}
title = reader.title
menu?.findItem(R.id.reader_menu_page_indicator)?.title =
"$currentPage/${reader.files.size}"
menu?.findItem(R.id.reader_type)?.icon = ContextCompat.getDrawable(
this@ReaderActivity,
R.drawable.hitomi
/*
when (reader.code) {
Code.HITOMI -> R.drawable.hitomi
Code.HIYOBI -> R.drawable.ic_hiyobi
else -> android.R.color.transparent
}*/
)
}
}
if (downloader.isCompleted(galleryID)) { //Download finished
binding.downloadProgressbar.visibility = View.GONE
animateDownloadFAB(false)
}
}
}
}
private fun initView() {
with (binding.recyclerview) {
adapter = ReaderAdapter(this@ReaderActivity, galleryID).apply {
onItemClickListener = {
if (isScroll) {
isScroll = false
isFullscreen = true
scrollMode(false)
fullscreen(true)
} else {
(binding.recyclerview.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(currentPage, 0) //Moves to next page because currentPage is 1-based indexing
}
}
}
addOnScrollListener(object: RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
@@ -331,27 +231,10 @@ class ReaderActivity : BaseActivity() {
})
}
with (binding.downloadFab) {
animateDownloadFAB(DownloadManager.getInstance(this@ReaderActivity).getDownloadFolder(galleryID) != null) //If download in progress, animate button
setOnClickListener {
val downloadManager = DownloadManager.getInstance(this@ReaderActivity)
if (downloadManager.isDownloading(galleryID)) {
downloadManager.deleteDownloadFolder(galleryID)
animateDownloadFAB(false)
} else {
downloadManager.addDownloadFolder(galleryID)
DownloadService.download(context, galleryID, true)
animateDownloadFAB(true)
}
}
}
with (binding.retryFab) {
setImageResource(R.drawable.refresh)
setOnClickListener {
DownloadService.download(context, galleryID)
DownloadService.download(context, itemID)
}
}
@@ -399,7 +282,11 @@ class ReaderActivity : BaseActivity() {
private fun scrollMode(isScroll: Boolean) {
if (isScroll) {
snapHelper.attachToRecyclerView(null)
binding.recyclerview.layoutManager = LinearLayoutManager(this)
binding.recyclerview.layoutManager = object: LinearLayoutManager(this) {
override fun calculateExtraLayoutSpace(state: RecyclerView.State, extraLayoutSpace: IntArray) {
extraLayoutSpace.fill(600)
}
}
} else {
snapHelper.attachToRecyclerView(binding.recyclerview)
binding.recyclerview.layoutManager = object: LinearLayoutManager(this, HORIZONTAL, Preferences["rtl", false]) {
@@ -411,33 +298,4 @@ class ReaderActivity : BaseActivity() {
(binding.recyclerview.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(currentPage-1, 0)
}
private fun animateDownloadFAB(animate: Boolean) {
with (binding.downloadFab) {
if (animate) {
val icon = AnimatedVectorDrawableCompat.create(context, R.drawable.ic_downloading)
icon?.registerAnimationCallback(object : Animatable2Compat.AnimationCallback() {
override fun onAnimationEnd(drawable: Drawable?) {
if (downloader?.isCompleted(galleryID) == true) // If download is finished, stop animating
post {
setImageResource(R.drawable.ic_download)
labelText = getString(R.string.reader_fab_download_cancel)
}
else // Or continue animate
post {
icon.start()
labelText = getString(R.string.reader_fab_download_cancel)
}
}
})
setImageDrawable(icon)
icon?.start()
} else {
setImageResource(R.drawable.ic_download)
labelText = getString(R.string.reader_fab_download)
}
}
}
}

View File

@@ -21,17 +21,11 @@ package xyz.quaver.pupil.ui.dialog
import android.app.Dialog
import android.os.Bundle
import android.view.ViewGroup
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.DialogFragment
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.runBlocking
import xyz.quaver.pupil.R
import xyz.quaver.pupil.databinding.DownloadFolderNameDialogBinding
import xyz.quaver.pupil.util.Preferences
import xyz.quaver.pupil.util.downloader.Cache
import xyz.quaver.pupil.util.formatDownloadFolder
import xyz.quaver.pupil.util.formatDownloadFolderTest
import xyz.quaver.pupil.util.formatMap
class DownloadFolderNameDialogFragment : DialogFragment() {
@@ -55,16 +49,6 @@ class DownloadFolderNameDialogFragment : DialogFragment() {
}
private fun initView() {
val galleryID = Cache.instances.let { if (it.size == 0) "1199708" else it.keys.elementAt((0 until it.size).random()) }
val galleryBlock = runBlocking {
Cache.getInstance(requireContext(), galleryID).getGalleryBlock()
}
binding.message.text = getString(R.string.settings_download_folder_name_message, formatMap.keys.toString(), galleryBlock?.formatDownloadFolder() ?: "")
binding.edittext.setText(Preferences["download_folder_name", "[-id-] -title-"])
binding.edittext.addTextChangedListener {
binding.message.text = requireContext().getString(R.string.settings_download_folder_name_message, formatMap.keys.toString(), galleryBlock?.formatDownloadFolderTest(it.toString()) ?: "")
}
binding.okButton.setOnClickListener {
val newValue = binding.edittext.text.toString()

View File

@@ -43,7 +43,7 @@ import xyz.quaver.pupil.adapters.ThumbnailPageAdapter
import xyz.quaver.pupil.databinding.*
import xyz.quaver.pupil.favoriteTags
import xyz.quaver.pupil.sources.Hitomi
import xyz.quaver.pupil.sources.SearchResult
import xyz.quaver.pupil.sources.ItemInfo
import xyz.quaver.pupil.types.Tag
import xyz.quaver.pupil.ui.ReaderActivity
import xyz.quaver.pupil.ui.view.TagChip
@@ -200,7 +200,7 @@ class GalleryDialog(context: Context, private val galleryID: String) : AlertDial
}
private fun addRelated(gallery: Gallery) {
val galleries = mutableListOf<SearchResult>()
val galleries = mutableListOf<ItemInfo>()
val adapter = SearchResultsAdapter(galleries).apply {
onChipClickedHandler = { tag ->
@@ -237,10 +237,6 @@ class GalleryDialog(context: Context, private val galleryID: String) : AlertDial
CoroutineScope(Dispatchers.IO).launch {
gallery.related.forEach { galleryID ->
Cache.getInstance(context, galleryID.toString()).getGalleryBlock()?.let {
galleries.add(Hitomi.transform(it))
}
withContext(Dispatchers.Main) {
adapter.notifyDataSetChanged()
}

View File

@@ -20,79 +20,63 @@ package xyz.quaver.pupil.util.downloader
import android.content.Context
import android.content.ContextWrapper
import android.net.Uri
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.Request
import xyz.quaver.hitomi.GalleryBlock
import xyz.quaver.hitomi.GalleryInfo
import xyz.quaver.io.FileX
import xyz.quaver.io.util.*
import xyz.quaver.pupil.client
import xyz.quaver.pupil.util.Preferences
import java.io.File
import java.io.IOException
import xyz.quaver.io.util.deleteRecursively
import xyz.quaver.io.util.getChild
import xyz.quaver.io.util.outputStream
import xyz.quaver.io.util.writeText
import xyz.quaver.pupil.sources.ItemInfo
import xyz.quaver.pupil.sources.sources
import java.io.InputStream
import java.util.concurrent.ConcurrentHashMap
@Serializable
data class Metadata(
var galleryBlock: GalleryBlock? = null,
var reader: GalleryInfo? = null,
var itemInfo: ItemInfo? = null,
var imageList: MutableList<String?>? = null
) {
fun copy(): Metadata = Metadata(galleryBlock, reader, imageList?.let { MutableList(it.size) { i -> it[i] } })
fun copy(): Metadata = Metadata(itemInfo, imageList?.let { MutableList(it.size) { i -> it[i] } })
}
class Cache private constructor(context: Context, val galleryID: String) : ContextWrapper(context) {
class Cache private constructor(context: Context, source: String, private val itemID: String) : ContextWrapper(context) {
companion object {
val instances = ConcurrentHashMap<String, Cache>()
fun getInstance(context: Context, galleryID: String) =
instances[galleryID] ?: synchronized(this) {
instances[galleryID] ?: Cache(context, galleryID).also { instances[galleryID] = it }
fun getInstance(context: Context, source: String, itemID: String): Cache {
val key = "$source/$itemID"
return instances[key] ?: synchronized(this) {
instances[key] ?: Cache(context, source, itemID).also { instances[key] = it }
}
}
@Synchronized
fun delete(context: Context, galleryID: String) {
File(context.cacheDir, "imageCache/$galleryID").deleteRecursively()
instances.remove(galleryID)
fun delete(source: String, itemID: String) {
val key = "$source/$itemID"
instances[key]?.cacheFolder?.deleteRecursively()
instances.remove("$source/$itemID")
}
}
init {
cacheFolder.mkdirs()
}
var metadata = kotlin.runCatching {
findFile(".metadata")?.readText()?.let {
Json.decodeFromString<Metadata>(it)
}
}.getOrNull() ?: Metadata()
val source = sources[source]!!
val downloadFolder: FileX?
get() = DownloadManager.getInstance(this).getDownloadFolder(galleryID)
get() = DownloadManager.getInstance(this).getDownloadFolder(source.name, itemID)
val cacheFolder: FileX
get() = FileX(this, cacheDir, "imageCache/$galleryID").also {
get() = FileX(this, cacheDir, "imageCache/$source/$itemID").also {
if (!it.exists())
it.mkdirs()
}
fun findFile(fileName: String): FileX? =
downloadFolder?.let { downloadFolder -> downloadFolder.getChild(fileName).let {
if (it.exists()) it else null
} } ?: cacheFolder.getChild(fileName).let {
if (it.exists()) it else null
}
val metadata: Metadata = kotlin.runCatching {
Json.decodeFromString<Metadata>(findFile(".metadata")!!.readText())
}.getOrDefault(Metadata())
@Suppress("BlockingMethodInNonBlockingContext")
fun setMetadata(change: (Metadata) -> Unit) {
@@ -108,156 +92,26 @@ class Cache private constructor(context: Context, val galleryID: String) : Conte
}
}
suspend fun getGalleryBlock(): GalleryBlock? {
val sources = listOf(
{ xyz.quaver.hitomi.getGalleryBlock(galleryID.toInt()) }
// { xyz.quaver.hiyobi.getGalleryBlock(galleryID) }
)
return metadata.galleryBlock
?: withContext(Dispatchers.IO) {
var galleryBlock: GalleryBlock? = null
for (source in sources) {
galleryBlock = try {
source.invoke()
} catch (e: Exception) { null }
if (galleryBlock != null)
break
}
galleryBlock?.also {
setMetadata { metadata -> metadata.galleryBlock = it }
}
}
}
@Suppress("BlockingMethodInNonBlockingContext")
suspend fun getThumbnail(): Uri =
findFile(".thumbnail")?.uri
?: getGalleryBlock()?.thumbnails?.firstOrNull()?.let { withContext(Dispatchers.IO) {
kotlin.runCatching {
val request = Request.Builder()
.url(it)
.build()
client.newCall(request).execute().also { if (it.code() != 200) throw IOException() }.body()?.use { it.bytes() }
}.getOrNull()?.let { thumbnail -> kotlin.runCatching {
cacheFolder.getChild(".thumbnail").also {
if (!it.exists())
it.createNewFile()
it.writeBytes(thumbnail)
}
}.getOrNull()?.uri }
} } ?: Uri.EMPTY
suspend fun getReader(): GalleryInfo? {
val mirrors = Preferences.get<String>("mirrors").let { if (it.isEmpty()) emptyList() else it.split('>') }
val sources = mapOf(
"hitomi" to { xyz.quaver.hitomi.getGalleryInfo(galleryID.toInt()) },
//Code.HIYOBI to { xyz.quaver.hiyobi.getReader(galleryID) }
)
return metadata.reader
?: withContext(Dispatchers.IO) {
var reader: GalleryInfo? = null
for (source in sources) {
reader = try {
source.value.invoke()
} catch (e: Exception) {
null
}
if (reader != null)
break
}
reader?.also {
setMetadata { metadata ->
metadata.reader = it
if (metadata.imageList == null)
metadata.imageList = MutableList(reader.files.size) { null }
}
}
}
}
fun getImage(index: Int): FileX? =
metadata.imageList?.getOrNull(index)?.let { findFile(it) }
@Suppress("BlockingMethodInNonBlockingContext")
fun putImage(index: Int, fileName: String, data: ByteArray) {
val file = cacheFolder.getChild(fileName)
if (!file.exists())
file.createNewFile()
file.writeBytes(data)
setMetadata { metadata -> metadata.imageList!![index] = fileName }
}
private val lock = ConcurrentHashMap<String, Mutex>()
@Suppress("BlockingMethodInNonBlockingContext")
fun moveToDownload() = CoroutineScope(Dispatchers.IO).launch {
val downloadFolder = downloadFolder ?: return@launch
if (lock[galleryID]?.isLocked == true)
return@launch
(lock[galleryID] ?: Mutex().also { lock[galleryID] = it }).withLock {
val cacheMetadata = cacheFolder.getChild(".metadata")
val downloadMetadata = downloadFolder.getChild(".metadata")
if (!cacheMetadata.exists())
return@launch
if (cacheMetadata.exists()) {
kotlin.runCatching {
if (!downloadMetadata.exists())
downloadMetadata.createNewFile()
downloadMetadata.writeText(Json.encodeToString(metadata))
}
}
val cacheThumbnail = cacheFolder.getChild(".thumbnail")
val downloadThumbnail = downloadFolder.getChild(".thumbnail")
if (cacheThumbnail.exists()) {
kotlin.runCatching {
if (!downloadThumbnail.exists())
downloadThumbnail.createNewFile()
downloadThumbnail.outputStream()?.use { target -> target.channel.truncate(0L); cacheThumbnail.inputStream()?.use { source ->
source.copyTo(target)
} }
cacheThumbnail.delete()
}
}
metadata.imageList?.forEach { imageName ->
imageName ?: return@forEach
val target = downloadFolder.getChild(imageName)
val source = cacheFolder.getChild(imageName)
if (!source.exists())
return@forEach
kotlin.runCatching {
if (!target.exists())
target.createNewFile()
target.outputStream()?.use { target -> target.channel.truncate(0L); source.inputStream()?.use { source ->
source.copyTo(target)
} }
}
}
cacheFolder.deleteRecursively()
private fun findFile(fileName: String): FileX? =
downloadFolder?.let { downloadFolder -> downloadFolder.getChild(fileName).let {
if (it.exists()) it else null
} } ?: cacheFolder.getChild(fileName).let {
if (it.exists()) it else null
}
fun putImage(index: Int, name: String, `is`: InputStream) {
cacheFolder.getChild(name).also {
if (!it.exists())
it.createNewFile()
}.outputStream()?.use {
it.channel.truncate(0L)
`is`.copyTo(it)
}
setMetadata { metadata -> metadata.imageList!![index] = name }
}
fun getImage(index: Int): FileX? {
return metadata.imageList?.get(index)?.let { findFile(it) }
}
}

View File

@@ -20,16 +20,15 @@ package xyz.quaver.pupil.util.downloader
import android.content.Context
import android.content.ContextWrapper
import android.util.Log
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.Call
import xyz.quaver.io.FileX
import xyz.quaver.io.util.*
import xyz.quaver.pupil.client
import xyz.quaver.pupil.services.DownloadService
import xyz.quaver.pupil.sources.sources
import xyz.quaver.pupil.util.Preferences
import xyz.quaver.pupil.util.formatDownloadFolder
@@ -83,44 +82,33 @@ class DownloadManager private constructor(context: Context) : ContextWrapper(con
return downloadFolderMapInstance ?: mutableMapOf()
}
@Synchronized
fun getDownloadFolder(source: String, itemID: String): FileX? =
downloadFolderMap["$source-$itemID"]?.let { downloadFolder.getChild(it) }
@Synchronized
fun isDownloading(galleryID: String): Boolean {
val isThisGallery: (Call) -> Boolean = { (it.request().tag() as? DownloadService.Tag)?.galleryID == galleryID }
fun addDownloadFolder(source: String, itemID: String) = CoroutineScope(Dispatchers.IO).launch {
val name = "A" // TODO
return downloadFolderMap.containsKey(galleryID)
&& client.dispatcher().let { it.queuedCalls().any(isThisGallery) || it.runningCalls().any(isThisGallery) }
}
@Synchronized
fun getDownloadFolder(galleryID: String): FileX? =
downloadFolderMap[galleryID]?.let { downloadFolder.getChild(it) }
@Synchronized
fun addDownloadFolder(galleryID: String) {
val name = runBlocking {
Cache.getInstance(this@DownloadManager, galleryID).getGalleryBlock()
}?.formatDownloadFolder() ?: return
val folder = downloadFolder.getChild(name)
val folder = downloadFolder.getChild("$source/$name")
if (folder.exists())
return
return@launch
folder.mkdir()
downloadFolderMap[galleryID] = folder.name
downloadFolderMap["$source/$itemID"] = folder.name
downloadFolder.getChild(".download").let { if (!it.exists()) it.createNewFile() }
downloadFolder.getChild(".download").writeText(Json.encodeToString(downloadFolderMap))
}
@Synchronized
fun deleteDownloadFolder(galleryID: String) {
downloadFolderMap[galleryID]?.let {
fun deleteDownloadFolder(source: String, itemID: String) {
downloadFolderMap["$source/$itemID"]?.let {
kotlin.runCatching {
downloadFolder.getChild(it).deleteRecursively()
downloadFolderMap.remove(galleryID)
downloadFolderMap.remove("$source/$itemID")
downloadFolder.getChild(".download").let { if (!it.exists()) it.createNewFile() }
downloadFolder.getChild(".download").writeText(Json.encodeToString(downloadFolderMap))

View File

@@ -0,0 +1,221 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2020 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.util.downloader
import android.content.Context
import com.google.firebase.crashlytics.FirebaseCrashlytics
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import okhttp3.*
import okio.*
import xyz.quaver.pupil.PupilInterceptor
import xyz.quaver.pupil.client
import xyz.quaver.pupil.interceptors
import xyz.quaver.pupil.sources.sources
import xyz.quaver.pupil.util.cleanCache
import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
private typealias ProgressListener = (Downloader.Tag, Long, Long, Boolean) -> Unit
class Downloader private constructor(private val context: Context) {
data class Tag(val source: String, val itemID: String, val index: Int)
companion object {
var instance: Downloader? = null
fun getInstance(context: Context): Downloader {
return instance ?: synchronized(this) {
instance ?: Downloader(context).also {
interceptors[Tag::class] = it.interceptor
instance = it
}
}
}
}
//region ProgressListener
@Suppress("UNCHECKED_CAST")
private val progressListener: ProgressListener = { (source, itemID, index), bytesRead, contentLength, done ->
if (!done && progress["$source-$itemID"]?.get(index)?.isFinite() == true)
progress["$source-$itemID"]?.set(index, bytesRead * 100F / contentLength)
}
private class ProgressResponseBody(
val tag: Any?,
val responseBody: ResponseBody,
val progressListener : ProgressListener
) : ResponseBody() {
private var bufferedSource : BufferedSource? = null
override fun contentLength() = responseBody.contentLength()
override fun contentType() = responseBody.contentType()
override fun source(): BufferedSource {
if (bufferedSource == null)
bufferedSource = Okio.buffer(source(responseBody.source()))
return bufferedSource!!
}
private fun source(source: Source) = object: ForwardingSource(source) {
var totalBytesRead = 0L
override fun read(sink: Buffer, byteCount: Long): Long {
val bytesRead = super.read(sink, byteCount)
totalBytesRead += if (bytesRead == -1L) 0L else bytesRead
progressListener.invoke(tag as Tag, totalBytesRead, responseBody.contentLength(), bytesRead == -1L)
return bytesRead
}
}
}
private val interceptor: PupilInterceptor = { chain ->
val request = chain.request()
var response = chain.proceed(request)
var retry = 5
while (!response.isSuccessful && retry > 0) {
response = chain.proceed(request)
retry--
}
response.newBuilder()
.body(response.body()?.let {
ProgressResponseBody(request.tag(), it, progressListener)
}).build()
}
//endregion
private val callback = object : Callback {
override fun onFailure(call: Call, e: IOException) {
val (source, itemID, index) = call.request().tag() as Tag
FirebaseCrashlytics.getInstance().recordException(e)
progress["$source-$itemID"]?.set(index, Float.NEGATIVE_INFINITY)
}
override fun onResponse(call: Call, response: Response) {
val (source, itemID, index) = call.request().tag() as Tag
val ext = call.request().url().encodedPath().takeLastWhile { it != '.' }
if (response.code() != 200)
throw IOException()
response.body()?.use {
Cache.getInstance(context, source, itemID).putImage(index, "$index.$ext", it.byteStream())
}
progress["$source-$itemID"]?.set(index, Float.POSITIVE_INFINITY)
}
}
private val progress = ConcurrentHashMap<String, MutableList<Float>>()
fun getProgress(source: String, itemID: String): List<Float>? {
return progress["$source-$itemID"]
}
fun cancel() {
client.dispatcher().queuedCalls().filter {
it.request().tag() is Tag
}.forEach {
it.cancel()
}
client.dispatcher().runningCalls().filter {
it.request().tag() is Tag
}.forEach {
it.cancel()
}
progress.clear()
}
fun cancel(source: String, itemID: String) {
client.dispatcher().queuedCalls().filter {
(it.request().tag() as? Tag)?.let { tag ->
tag.source == source && tag.itemID == itemID
} == true
}.forEach {
it.cancel()
}
client.dispatcher().runningCalls().filter {
(it.request().tag() as? Tag)?.let { tag ->
tag.source == source && tag.itemID == itemID
} == true
}.forEach {
it.cancel()
}
progress.remove("$source-$itemID")
}
fun retry(source: String, itemID: String) {
cancel(source, itemID)
download(source, itemID)
}
var onImageListLoadedCallback: ((List<String>) -> Unit)? = null
fun download(source: String, itemID: String) = CoroutineScope(Dispatchers.IO).launch {
if (isDownloading(source, itemID))
return@launch
cleanCache(context)
val source = sources[source] ?: return@launch
val cache = Cache.getInstance(context, source.name, itemID)
source.images(itemID).also {
progress["${source.name}-$itemID"] = MutableList(it.size) { i ->
if (cache.metadata.imageList?.get(i) == null) 0F else Float.POSITIVE_INFINITY
}
with (Cache.getInstance(context, source.name, itemID).metadata) {
if (imageList == null)
imageList = MutableList(it.size) { null }
imageList!!.forEachIndexed { index, s ->
if (s != null)
progress["${source.name}-$itemID"]?.set(index, Float.POSITIVE_INFINITY)
}
}
onImageListLoadedCallback?.invoke(it)
}.forEachIndexed { index, url ->
client.newCall(
Request.Builder()
.tag(Tag(source.name, itemID, index))
.url(url)
.headers(Headers.of(source.getHeadersForImage(itemID, url)))
.build()
).enqueue(callback)
}
}
fun isDownloading(source: String, itemID: String): Boolean {
return (client.dispatcher().queuedCalls() + client.dispatcher().runningCalls()).any {
(it.request().tag() as? Tag)?.let { tag ->
tag.source == source && tag.itemID == itemID
} == true
}
}
}

View File

@@ -57,9 +57,9 @@ fun cleanCache(context: Context) = CoroutineScope(Dispatchers.IO).launch {
synchronized(histories) {
(histories.firstOrNull {
caches.contains(it.toString()) && !downloadManager.isDownloading(it)
TODO()
} ?: return@withLock).let {
Cache.delete(context, it)
TODO()
}
}
}

View File

@@ -19,19 +19,14 @@
package xyz.quaver.pupil.util
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.os.Build
import android.view.MenuItem
import androidx.core.content.ContextCompat
import kotlinx.serialization.json.*
import okhttp3.OkHttpClient
import okhttp3.Request
import xyz.quaver.hitomi.GalleryBlock
import xyz.quaver.hitomi.GalleryInfo
import xyz.quaver.hitomi.getReferer
import xyz.quaver.hitomi.imageUrlFromImage
import xyz.quaver.hiyobi.createImgList
import xyz.quaver.pupil.sources.ItemInfo
import java.util.*
import kotlin.collections.ArrayList
@@ -80,23 +75,23 @@ fun OkHttpClient.Builder.proxyInfo(proxyInfo: ProxyInfo) = this.apply {
}
}
val formatMap = mapOf<String, GalleryBlock.() -> (String)>(
"-id-" to { id.toString() },
val formatMap = mapOf<String, ItemInfo.() -> (String)>(
"-id-" to { id },
"-title-" to { title },
"-artist-" to { artists.joinToString() }
"-artist-" to { artists }
// TODO
)
/**
* Formats download folder name with given Metadata
*/
fun GalleryBlock.formatDownloadFolder(): String =
fun ItemInfo.formatDownloadFolder(): String =
Preferences["download_folder_name", "[-id-] -title-"].let {
formatMap.entries.fold(it) { str, (k, v) ->
str.replace(k, v.invoke(this), true)
}
}.replace(Regex("""[*\\|"?><:/]"""), "").ellipsize(127)
fun GalleryBlock.formatDownloadFolderTest(format: String): String =
fun ItemInfo.formatDownloadFolderTest(format: String): String =
format.let {
formatMap.entries.fold(it) { str, (k, v) ->
str.replace(k, v.invoke(this), true)