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

@@ -67,6 +67,7 @@ android {
} }
buildFeatures { buildFeatures {
viewBinding true viewBinding true
dataBinding true
} }
kotlinOptions { kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString() jvmTarget = JavaVersion.VERSION_1_8.toString()
@@ -86,8 +87,8 @@ dependencies {
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.0.1" implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.0.1"
implementation "androidx.appcompat:appcompat:1.2.0" implementation "androidx.appcompat:appcompat:1.2.0"
implementation "androidx.activity:activity-ktx:1.2.0-beta01" implementation "androidx.activity:activity-ktx:1.2.0-rc01"
implementation "androidx.fragment:fragment-ktx:1.3.0-beta01" implementation "androidx.fragment:fragment-ktx:1.3.0-rc01"
implementation "androidx.preference:preference-ktx:1.1.1" implementation "androidx.preference:preference-ktx:1.1.1"
implementation "androidx.recyclerview:recyclerview:1.1.0" implementation "androidx.recyclerview:recyclerview:1.1.0"
implementation "androidx.constraintlayout:constraintlayout:2.0.4" implementation "androidx.constraintlayout:constraintlayout:2.0.4"
@@ -97,7 +98,7 @@ dependencies {
implementation "com.daimajia.swipelayout:library:1.2.0@aar" implementation "com.daimajia.swipelayout:library:1.2.0@aar"
implementation "com.google.android.material:material:1.3.0-alpha04" implementation "com.google.android.material:material:1.3.0-beta01"
implementation platform("com.google.firebase:firebase-bom:26.1.0") implementation platform("com.google.firebase:firebase-bom:26.1.0")
implementation "com.google.firebase:firebase-analytics-ktx" implementation "com.google.firebase:firebase-analytics-ktx"
@@ -105,15 +106,15 @@ dependencies {
implementation "com.google.firebase:firebase-perf" implementation "com.google.firebase:firebase-perf"
implementation "com.google.android.gms:play-services-oss-licenses:17.0.0" implementation "com.google.android.gms:play-services-oss-licenses:17.0.0"
implementation "com.google.android.gms:play-services-mlkit-face-detection:16.1.1" implementation "com.google.android.gms:play-services-mlkit-face-detection:16.1.2"
implementation "com.github.clans:fab:1.6.4" implementation "com.github.clans:fab:1.6.4"
//implementation "com.quiph.ui:recyclerviewfastscroller:0.2.1" //implementation "com.quiph.ui:recyclerviewfastscroller:0.2.1"
implementation 'com.github.piasy:BigImageViewer:1.6.7' implementation 'com.github.piasy:BigImageViewer:1.7.0'
implementation 'com.github.piasy:FrescoImageLoader:1.6.7' implementation 'com.github.piasy:FrescoImageLoader:1.7.0'
implementation 'com.github.piasy:FrescoImageViewFactory:1.6.7' implementation 'com.github.piasy:FrescoImageViewFactory:1.7.0'
//noinspection GradleDependency //noinspection GradleDependency
implementation "com.squareup.okhttp3:okhttp:$okhttp_version" implementation "com.squareup.okhttp3:okhttp:$okhttp_version"

View File

@@ -3,6 +3,7 @@
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
package="xyz.quaver.pupil"> package="xyz.quaver.pupil">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" /> <uses-permission android:name="android.permission.USE_BIOMETRIC" />

View File

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

View File

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

View File

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

View File

@@ -50,6 +50,7 @@ import kotlin.math.ceil
import kotlin.math.log10 import kotlin.math.log10
private typealias ProgressListener = (DownloadService.Tag, Long, Long, Boolean) -> Unit private typealias ProgressListener = (DownloadService.Tag, Long, Long, Boolean) -> Unit
@Deprecated(message = "Use xyz.quaver.util.downloader.Downloader")
class DownloadService : Service() { class DownloadService : Service() {
data class Tag(val galleryID: String, val index: Int, val startId: Int? = null) data class Tag(val galleryID: String, val index: Int, val startId: Int? = null)
@@ -119,11 +120,6 @@ class DownloadService : Service() {
notification notification
.setProgress(max, progress, false) .setProgress(max, progress, false)
.setContentText("$progress/$max") .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 //endregion
@@ -214,34 +210,6 @@ class DownloadService : Service() {
} }
override fun onResponse(call: Call, response: Response) { 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 { 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 { 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 //endregion

View File

@@ -21,20 +21,27 @@ package xyz.quaver.pupil.sources
import android.content.Context import android.content.Context
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.parcelize.Parcelize 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.databinding.SearchSuggestionItemBinding
import xyz.quaver.floatingsearchview.suggestions.model.SearchSuggestion import xyz.quaver.floatingsearchview.suggestions.model.SearchSuggestion
import xyz.quaver.pupil.R import xyz.quaver.pupil.R
data class SearchResult( @Serializable(with = ItemInfo.SearchResultSerializer::class)
data class ItemInfo(
val source: String,
val id: String, val id: String,
val title: String, val title: String,
val thumbnail: String, val thumbnail: String,
val artists: 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 { enum class ExtraType {
GROUP, GROUP,
@@ -45,6 +52,48 @@ data class SearchResult(
PAGECOUNT 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 { companion object {
val extraTypeMap = mapOf( val extraTypeMap = mapOf(
ExtraType.SERIES to R.string.galleryblock_series, 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 iconResID: Int
abstract val availableSortMode: Array<Query_SortMode> 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 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) { open fun onSuggestionBind(binding: SearchSuggestionItemBinding, item: Suggestion) {
binding.leftIcon.setImageResource(R.drawable.tag) binding.leftIcon.setImageResource(R.drawable.tag)

View File

@@ -18,18 +18,18 @@
package xyz.quaver.pupil.sources package xyz.quaver.pupil.sources
import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.widget.TextView import android.widget.TextView
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import okhttp3.Request
import xyz.quaver.floatingsearchview.databinding.SearchSuggestionItemBinding import xyz.quaver.floatingsearchview.databinding.SearchSuggestionItemBinding
import xyz.quaver.floatingsearchview.suggestions.model.SearchSuggestion import xyz.quaver.floatingsearchview.suggestions.model.SearchSuggestion
import xyz.quaver.hitomi.* import xyz.quaver.hitomi.*
import xyz.quaver.pupil.R 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.translations
import xyz.quaver.pupil.util.wordCapitalize import xyz.quaver.pupil.util.wordCapitalize
import kotlin.math.max import kotlin.math.max
@@ -63,7 +63,7 @@ class Hitomi : Source<Hitomi.SortMode, Hitomi.TagSuggestion>() {
var cachedSortMode: SortMode? = null var cachedSortMode: SortMode? = null
val cache = mutableListOf<Int>() 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()) { if (cachedQuery != query || cachedSortMode != sortMode || cache.isEmpty()) {
cachedQuery = null cachedQuery = null
cache.clear() cache.clear()
@@ -75,7 +75,7 @@ class Hitomi : Source<Hitomi.SortMode, Hitomi.TagSuggestion>() {
cachedQuery = query cachedQuery = query
} }
val channel = Channel<SearchResult>() val channel = Channel<ItemInfo>()
val sanitizedRange = max(0, range.first) .. min(range.last, cache.size-1) val sanitizedRange = max(0, range.first) .. min(range.last, cache.size-1)
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
@@ -84,12 +84,7 @@ class Hitomi : Source<Hitomi.SortMode, Hitomi.TagSuggestion>() {
getGalleryBlock(it) getGalleryBlock(it)
} }
}.forEach { }.forEach {
kotlin.runCatching { channel.send(transform(name, it.await()))
yield()
channel.send(transform(it.await()))
}.onFailure {
channel.close()
}
} }
channel.close() channel.close()
@@ -98,19 +93,23 @@ class Hitomi : Source<Hitomi.SortMode, Hitomi.TagSuggestion>() {
return Pair(channel, cache.size) 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 galleryID = id.toInt()
val reader = getGalleryInfo(galleryID) val reader = getGalleryInfo(galleryID)
return reader.files.map { return reader.files.map {
Request.Builder() imageUrlFromImage(galleryID, it, true)
.url(imageUrlFromImage(galleryID, it, true))
.header("Referer", getReferer(galleryID))
} }
} }
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 { return getSuggestionsForQuery(query.takeLastWhile { !it.isWhitespace() }).map {
TagSuggestion(it) TagSuggestion(it)
} }
@@ -189,20 +188,25 @@ class Hitomi : Source<Hitomi.SortMode, Hitomi.TagSuggestion>() {
"japanese" to "日本語" "japanese" to "日本語"
) )
fun transform(galleryBlock: GalleryBlock): SearchResult = fun transform(name: String, galleryBlock: GalleryBlock) =
SearchResult( ItemInfo(
name,
galleryBlock.id.toString(), galleryBlock.id.toString(),
galleryBlock.title, galleryBlock.title,
galleryBlock.thumbnails.first(), galleryBlock.thumbnails.first(),
galleryBlock.artists.joinToString { it.wordCapitalize() }, galleryBlock.artists.joinToString { it.wordCapitalize() },
galleryBlock.relatedTags,
mapOf( mapOf(
ExtraType.GROUP to { getGallery(galleryBlock.id).groups.joinToString { it.wordCapitalize() } }, ExtraType.GROUP to CoroutineScope(Dispatchers.IO).async { kotlin.runCatching {
ExtraType.SERIES to { galleryBlock.series.joinToString { it.wordCapitalize() } }, getGallery(galleryBlock.id).groups.joinToString { it.wordCapitalize() }
ExtraType.TYPE to { galleryBlock.type.wordCapitalize() }, }.getOrDefault("") },
ExtraType.LANGUAGE to { languageMap[galleryBlock.language] ?: galleryBlock.language }, ExtraType.SERIES to CoroutineScope(Dispatchers.Unconfined).async { galleryBlock.series.joinToString { it.wordCapitalize() } },
ExtraType.PAGECOUNT to { getGalleryInfo(galleryBlock.id).files.size.toString() } ExtraType.TYPE to CoroutineScope(Dispatchers.Unconfined).async { galleryBlock.type.wordCapitalize() },
), ExtraType.LANGUAGE to CoroutineScope(Dispatchers.Unconfined).async { languageMap[galleryBlock.language] ?: galleryBlock.language },
galleryBlock.relatedTags 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 iconResID: Int = R.drawable.ic_hiyobi
override val availableSortMode: Array<DefaultSortMode> = DefaultSortMode.values() override val availableSortMode: Array<DefaultSortMode> = DefaultSortMode.values()
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> {
val channel = Channel<SearchResult>() val channel = Channel<ItemInfo>()
val (results, total) = if (query.isEmpty()) val (results, total) = if (query.isEmpty())
list(range) list(range)
@@ -47,7 +47,7 @@ class Hiyobi : Source<DefaultSortMode, DefaultSearchSuggestion>() {
CoroutineScope(Dispatchers.Unconfined).launch { CoroutineScope(Dispatchers.Unconfined).launch {
results.forEach { results.forEach {
channel.send(transform(it)) channel.send(transform(name, it))
} }
channel.close() channel.close()
@@ -72,10 +72,9 @@ class Hiyobi : Source<DefaultSortMode, DefaultSearchSuggestion>() {
return result.map { DefaultSearchSuggestion(it) } 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 { return createImgList(id, getGalleryInfo(id), true).map {
Request.Builder() it.path
.url(it.path)
} }
} }
@@ -115,21 +114,23 @@ class Hiyobi : Source<DefaultSortMode, DefaultSearchSuggestion>() {
_allTags = it _allTags = it
} else _allTags!! } else _allTags!!
fun transform(galleryBlock: GalleryBlock): SearchResult = suspend fun transform(name: String, galleryBlock: GalleryBlock): ItemInfo = withContext(Dispatchers.IO) {
SearchResult( ItemInfo(
name,
galleryBlock.id, galleryBlock.id,
galleryBlock.title, galleryBlock.title,
"https://cdn.$hiyobi/tn/${galleryBlock.id}.jpg", "https://cdn.$hiyobi/tn/${galleryBlock.id}.jpg",
galleryBlock.artists.joinToString { it.value.wordCapitalize() }, galleryBlock.artists.joinToString { it.value.wordCapitalize() },
galleryBlock.tags.map { it.value },
mapOf( mapOf(
SearchResult.ExtraType.CHARACTER to { galleryBlock.characters.joinToString { it.value.wordCapitalize() } }, ItemInfo.ExtraType.CHARACTER to async { galleryBlock.characters.joinToString { it.value.wordCapitalize() } },
SearchResult.ExtraType.SERIES to { galleryBlock.parodys.joinToString { it.value.wordCapitalize() } }, ItemInfo.ExtraType.SERIES to async { galleryBlock.parodys.joinToString { it.value.wordCapitalize() } },
SearchResult.ExtraType.TYPE to { galleryBlock.type.name.replace('_', ' ').wordCapitalize() }, ItemInfo.ExtraType.TYPE to async { galleryBlock.type.name.replace('_', ' ').wordCapitalize() },
SearchResult.ExtraType.PAGECOUNT to { getGalleryInfo(galleryBlock.id).files.size.toString() }, ItemInfo.ExtraType.PAGECOUNT to async { getGalleryInfo(galleryBlock.id).files.size.toString() },
SearchResult.ExtraType.GROUP to { galleryBlock.groups.joinToString { it.value.wordCapitalize() } } ItemInfo.ExtraType.GROUP to async { galleryBlock.groups.joinToString { it.value.wordCapitalize() } }
),
galleryBlock.tags.map { it.value }
) )
)
}
} }
} }

View File

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

View File

@@ -18,28 +18,18 @@
package xyz.quaver.pupil.ui package xyz.quaver.pupil.ui
import android.content.ComponentName
import android.content.Intent import android.content.Intent
import android.content.ServiceConnection
import android.graphics.drawable.Animatable import android.graphics.drawable.Animatable
import android.graphics.drawable.Drawable
import android.os.Bundle import android.os.Bundle
import android.os.IBinder
import android.view.* import android.view.*
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.PagerSnapHelper import androidx.recyclerview.widget.PagerSnapHelper
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.vectordrawable.graphics.drawable.Animatable2Compat
import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
import com.google.android.material.snackbar.Snackbar
import com.google.firebase.crashlytics.FirebaseCrashlytics import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.qtalk.recyclerviewfastscroller.RecyclerViewFastScroller 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.R
import xyz.quaver.pupil.adapters.ReaderAdapter import xyz.quaver.pupil.adapters.ReaderAdapter
import xyz.quaver.pupil.databinding.NumberpickerDialogBinding 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.favorites
import xyz.quaver.pupil.services.DownloadService import xyz.quaver.pupil.services.DownloadService
import xyz.quaver.pupil.util.Preferences import xyz.quaver.pupil.util.Preferences
import xyz.quaver.pupil.util.downloader.Cache import xyz.quaver.pupil.util.downloader.Downloader
import xyz.quaver.pupil.util.downloader.DownloadManager
class ReaderActivity : BaseActivity() { class ReaderActivity : BaseActivity() {
private var galleryID = "" private var source = ""
private var itemID = ""
private var currentPage = 0 private var currentPage = 0
private var isScroll = true private var isScroll = true
@@ -60,24 +51,7 @@ class ReaderActivity : BaseActivity() {
set(value) { set(value) {
field = value field = value
(binding.recyclerview.adapter as ReaderAdapter).isFullScreen = 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
}
} }
private val snapHelper = PagerSnapHelper() private val snapHelper = PagerSnapHelper()
@@ -94,15 +68,36 @@ class ReaderActivity : BaseActivity() {
supportActionBar?.setDisplayHomeAsUpEnabled(false) supportActionBar?.setDisplayHomeAsUpEnabled(false)
handleIntent(intent) handleIntent(intent)
cache = Cache.getInstance(this, galleryID) FirebaseCrashlytics.getInstance().setCustomKey("GalleryID", itemID)
FirebaseCrashlytics.getInstance().setCustomKey("GalleryID", galleryID)
if (galleryID.isEmpty()) { if (itemID.isEmpty()) {
onBackPressed() onBackPressed()
return 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() initView()
} }
@@ -116,7 +111,8 @@ class ReaderActivity : BaseActivity() {
val uri = intent.data val uri = intent.data
val lastPathSegment = uri?.lastPathSegment val lastPathSegment = uri?.lastPathSegment
if (uri != null && lastPathSegment != null) { if (uri != null && lastPathSegment != null) {
galleryID = when (uri.host) { source = uri.host ?: ""
itemID = when (uri.host) {
"hitomi.la" -> "hitomi.la" ->
Regex("([0-9]+).html").find(lastPathSegment)?.groupValues?.get(1) ?: "" Regex("([0-9]+).html").find(lastPathSegment)?.groupValues?.get(1) ?: ""
"hiyobi.me" -> lastPathSegment "hiyobi.me" -> lastPathSegment
@@ -125,7 +121,8 @@ class ReaderActivity : BaseActivity() {
} }
} }
} else { } 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)) { with (menu?.findItem(R.id.reader_menu_favorite)) {
this ?: return@with this ?: return@with
if (favorites.contains(galleryID)) if (favorites.contains(itemID))
(icon as Animatable).start() (icon as Animatable).start()
} }
@@ -151,7 +148,7 @@ class ReaderActivity : BaseActivity() {
with (binding.numberPicker) { with (binding.numberPicker) {
minValue = 1 minValue = 1
maxValue = cache.metadata.reader?.files?.size ?: 0 maxValue = this@ReaderActivity.binding.recyclerview.adapter?.itemCount ?: 0
value = currentPage value = currentPage
} }
val dialog = AlertDialog.Builder(this).apply { val dialog = AlertDialog.Builder(this).apply {
@@ -165,7 +162,7 @@ class ReaderActivity : BaseActivity() {
dialog.show() dialog.show()
} }
R.id.reader_menu_favorite -> { R.id.reader_menu_favorite -> {
val id = galleryID val id = itemID
val favorite = menu?.findItem(R.id.reader_menu_favorite) ?: return true val favorite = menu?.findItem(R.id.reader_menu_favorite) ?: return true
if (favorites.contains(id)) { if (favorites.contains(id)) {
@@ -181,30 +178,6 @@ class ReaderActivity : BaseActivity() {
return true 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() { override fun onBackPressed() {
if (isScroll and !isFullscreen) if (isScroll and !isFullscreen)
super.onBackPressed() 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() { private fun initView() {
with (binding.recyclerview) { 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() { addOnScrollListener(object: RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy) 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) { with (binding.retryFab) {
setImageResource(R.drawable.refresh) setImageResource(R.drawable.refresh)
setOnClickListener { setOnClickListener {
DownloadService.download(context, galleryID) DownloadService.download(context, itemID)
} }
} }
@@ -399,7 +282,11 @@ class ReaderActivity : BaseActivity() {
private fun scrollMode(isScroll: Boolean) { private fun scrollMode(isScroll: Boolean) {
if (isScroll) { if (isScroll) {
snapHelper.attachToRecyclerView(null) 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 { } else {
snapHelper.attachToRecyclerView(binding.recyclerview) snapHelper.attachToRecyclerView(binding.recyclerview)
binding.recyclerview.layoutManager = object: LinearLayoutManager(this, HORIZONTAL, Preferences["rtl", false]) { 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) (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.app.Dialog
import android.os.Bundle import android.os.Bundle
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.runBlocking
import xyz.quaver.pupil.R import xyz.quaver.pupil.R
import xyz.quaver.pupil.databinding.DownloadFolderNameDialogBinding import xyz.quaver.pupil.databinding.DownloadFolderNameDialogBinding
import xyz.quaver.pupil.util.Preferences 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() { class DownloadFolderNameDialogFragment : DialogFragment() {
@@ -55,16 +49,6 @@ class DownloadFolderNameDialogFragment : DialogFragment() {
} }
private fun initView() { 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 { binding.okButton.setOnClickListener {
val newValue = binding.edittext.text.toString() 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.databinding.*
import xyz.quaver.pupil.favoriteTags import xyz.quaver.pupil.favoriteTags
import xyz.quaver.pupil.sources.Hitomi 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.types.Tag
import xyz.quaver.pupil.ui.ReaderActivity import xyz.quaver.pupil.ui.ReaderActivity
import xyz.quaver.pupil.ui.view.TagChip 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) { private fun addRelated(gallery: Gallery) {
val galleries = mutableListOf<SearchResult>() val galleries = mutableListOf<ItemInfo>()
val adapter = SearchResultsAdapter(galleries).apply { val adapter = SearchResultsAdapter(galleries).apply {
onChipClickedHandler = { tag -> onChipClickedHandler = { tag ->
@@ -237,10 +237,6 @@ class GalleryDialog(context: Context, private val galleryID: String) : AlertDial
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
gallery.related.forEach { galleryID -> gallery.related.forEach { galleryID ->
Cache.getInstance(context, galleryID.toString()).getGalleryBlock()?.let {
galleries.add(Hitomi.transform(it))
}
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
adapter.notifyDataSetChanged() adapter.notifyDataSetChanged()
} }

View File

@@ -20,79 +20,63 @@ package xyz.quaver.pupil.util.downloader
import android.content.Context import android.content.Context
import android.content.ContextWrapper 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.Serializable
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json 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.FileX
import xyz.quaver.io.util.* import xyz.quaver.io.util.deleteRecursively
import xyz.quaver.pupil.client import xyz.quaver.io.util.getChild
import xyz.quaver.pupil.util.Preferences import xyz.quaver.io.util.outputStream
import java.io.File import xyz.quaver.io.util.writeText
import java.io.IOException import xyz.quaver.pupil.sources.ItemInfo
import xyz.quaver.pupil.sources.sources
import java.io.InputStream
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
@Serializable @Serializable
data class Metadata( data class Metadata(
var galleryBlock: GalleryBlock? = null, var itemInfo: ItemInfo? = null,
var reader: GalleryInfo? = null,
var imageList: MutableList<String?>? = 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 { companion object {
val instances = ConcurrentHashMap<String, Cache>() val instances = ConcurrentHashMap<String, Cache>()
fun getInstance(context: Context, galleryID: String) = fun getInstance(context: Context, source: String, itemID: String): Cache {
instances[galleryID] ?: synchronized(this) { val key = "$source/$itemID"
instances[galleryID] ?: Cache(context, galleryID).also { instances[galleryID] = it } return instances[key] ?: synchronized(this) {
instances[key] ?: Cache(context, source, itemID).also { instances[key] = it }
}
} }
@Synchronized @Synchronized
fun delete(context: Context, galleryID: String) { fun delete(source: String, itemID: String) {
File(context.cacheDir, "imageCache/$galleryID").deleteRecursively() val key = "$source/$itemID"
instances.remove(galleryID)
instances[key]?.cacheFolder?.deleteRecursively()
instances.remove("$source/$itemID")
} }
} }
init { val source = sources[source]!!
cacheFolder.mkdirs()
}
var metadata = kotlin.runCatching {
findFile(".metadata")?.readText()?.let {
Json.decodeFromString<Metadata>(it)
}
}.getOrNull() ?: Metadata()
val downloadFolder: FileX? val downloadFolder: FileX?
get() = DownloadManager.getInstance(this).getDownloadFolder(galleryID) get() = DownloadManager.getInstance(this).getDownloadFolder(source.name, itemID)
val cacheFolder: FileX val cacheFolder: FileX
get() = FileX(this, cacheDir, "imageCache/$galleryID").also { get() = FileX(this, cacheDir, "imageCache/$source/$itemID").also {
if (!it.exists()) if (!it.exists())
it.mkdirs() it.mkdirs()
} }
fun findFile(fileName: String): FileX? = val metadata: Metadata = kotlin.runCatching {
downloadFolder?.let { downloadFolder -> downloadFolder.getChild(fileName).let { Json.decodeFromString<Metadata>(findFile(".metadata")!!.readText())
if (it.exists()) it else null }.getOrDefault(Metadata())
} } ?: cacheFolder.getChild(fileName).let {
if (it.exists()) it else null
}
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
fun setMetadata(change: (Metadata) -> Unit) { fun setMetadata(change: (Metadata) -> Unit) {
@@ -108,156 +92,26 @@ class Cache private constructor(context: Context, val galleryID: String) : Conte
} }
} }
suspend fun getGalleryBlock(): GalleryBlock? { private fun findFile(fileName: String): FileX? =
val sources = listOf( downloadFolder?.let { downloadFolder -> downloadFolder.getChild(fileName).let {
{ xyz.quaver.hitomi.getGalleryBlock(galleryID.toInt()) } if (it.exists()) it else null
// { xyz.quaver.hiyobi.getGalleryBlock(galleryID) } } } ?: cacheFolder.getChild(fileName).let {
) if (it.exists()) it else null
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 { fun putImage(index: Int, name: String, `is`: InputStream) {
setMetadata { metadata -> metadata.galleryBlock = it } cacheFolder.getChild(name).also {
}
}
}
@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()) if (!it.exists())
it.createNewFile() it.createNewFile()
}.outputStream()?.use {
it.writeBytes(thumbnail) it.channel.truncate(0L)
} `is`.copyTo(it)
}.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) setMetadata { metadata -> metadata.imageList!![index] = name }
break
} }
reader?.also { fun getImage(index: Int): FileX? {
setMetadata { metadata -> return metadata.imageList?.get(index)?.let { findFile(it) }
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()
}
} }
} }

View File

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

View File

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

View File

@@ -20,7 +20,7 @@
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:layout_marginBottom="8dp" android:layout_marginBottom="8dp"
android:background="@drawable/reader_item_boundary"> android:background="@drawable/reader_item_boundary">

View File

@@ -21,4 +21,4 @@ android.enableJetifier=true
android.useAndroidX=true android.useAndroidX=true
android.enableBuildCache=true android.enableBuildCache=true
kotlin_version=1.4.20 kotlin_version=1.4.21