This commit is contained in:
tom5079
2021-01-09 19:18:26 +09:00
parent c8aa26e2d9
commit 619730e2ab
32 changed files with 825 additions and 1385 deletions

View File

@@ -54,12 +54,12 @@
<remote-repository> <remote-repository>
<option name="id" value="MavenLocal" /> <option name="id" value="MavenLocal" />
<option name="name" value="MavenLocal" /> <option name="name" value="MavenLocal" />
<option name="url" value="file:/$USER_HOME$/.m2/repository/" /> <option name="url" value="file:/$MAVEN_REPOSITORY$/" />
</remote-repository> </remote-repository>
<remote-repository> <remote-repository>
<option name="id" value="MavenLocal" /> <option name="id" value="MavenLocal" />
<option name="name" value="MavenLocal" /> <option name="name" value="MavenLocal" />
<option name="url" value="file:/$USER_HOME$/.m2/repository" /> <option name="url" value="file:/$MAVEN_REPOSITORY$" />
</remote-repository> </remote-repository>
<remote-repository> <remote-repository>
<option name="id" value="maven3" /> <option name="id" value="maven3" />
@@ -71,5 +71,10 @@
<option name="name" value="maven3" /> <option name="name" value="maven3" />
<option name="url" value="http://dl.bintray.com/piasy/maven" /> <option name="url" value="http://dl.bintray.com/piasy/maven" />
</remote-repository> </remote-repository>
<remote-repository>
<option name="id" value="maven" />
<option name="name" value="maven" />
<option name="url" value="https://dl.bintray.com/piasy/maven" />
</remote-repository>
</component> </component>
</project> </project>

View File

@@ -44,8 +44,10 @@ android {
} }
buildTypes { buildTypes {
debug { debug {
minifyEnabled true minifyEnabled false
shrinkResources true shrinkResources false
multiDexEnabled true
debuggable true debuggable true
applicationIdSuffix ".debug" applicationIdSuffix ".debug"
@@ -76,6 +78,10 @@ android {
targetCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8
} }
buildToolsVersion = "29.0.3" buildToolsVersion = "29.0.3"
lintOptions {
abortOnError false
}
} }
dependencies { dependencies {
@@ -94,6 +100,8 @@ dependencies {
implementation "androidx.biometric:biometric:1.0.1" implementation "androidx.biometric:biometric:1.0.1"
implementation "androidx.work:work-runtime-ktx:2.4.0" implementation "androidx.work:work-runtime-ktx:2.4.0"
implementation 'org.kodein.di:kodein-di-framework-android-x:7.1.0'
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-beta01" implementation "com.google.android.material:material:1.3.0-beta01"
@@ -104,7 +112,6 @@ 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.2"
implementation "com.github.clans:fab:1.6.4" implementation "com.github.clans:fab:1.6.4"
@@ -131,6 +138,8 @@ dependencies {
implementation "xyz.quaver:documentfilex:0.4-alpha02" implementation "xyz.quaver:documentfilex:0.4-alpha02"
implementation "xyz.quaver:floatingsearchview:1.1.1" implementation "xyz.quaver:floatingsearchview:1.1.1"
// debugImplementation"com.squareup.leakcanary:leakcanary-android:2.6"
testImplementation "junit:junit:4.13.1" testImplementation "junit:junit:4.13.1"
androidTestImplementation "androidx.test.ext:junit:1.1.2" androidTestImplementation "androidx.test.ext:junit:1.1.2"
androidTestImplementation "androidx.test:rules:1.3.0" androidTestImplementation "androidx.test:rules:1.3.0"

View File

@@ -26,7 +26,6 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.util.Log
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
@@ -39,22 +38,17 @@ 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.*
import okhttp3.Interceptor import org.kodein.di.*
import okhttp3.OkHttpClient import org.kodein.di.android.x.androidXModule
import okhttp3.Response
import xyz.quaver.io.FileX import xyz.quaver.io.FileX
import xyz.quaver.pupil.sources.initSources import xyz.quaver.pupil.sources.initSources
import xyz.quaver.pupil.sources.sourceModule
import xyz.quaver.pupil.types.Tag import xyz.quaver.pupil.types.Tag
import xyz.quaver.pupil.util.* 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 kotlin.reflect.KClass
typealias PupilInterceptor = (Interceptor.Chain) -> Response
lateinit var histories: SavedSet<String> lateinit var histories: SavedSet<String>
private set private set
@@ -65,8 +59,6 @@ lateinit var favoriteTags: SavedSet<Tag>
lateinit var searchHistory: SavedSet<String> lateinit var searchHistory: SavedSet<String>
private set private set
val interceptors = mutableMapOf<KClass<out Any>, PupilInterceptor>()
lateinit var clientBuilder: OkHttpClient.Builder lateinit var clientBuilder: OkHttpClient.Builder
var clientHolder: OkHttpClient? = null var clientHolder: OkHttpClient? = null
@@ -76,7 +68,16 @@ val client: OkHttpClient
setClient(it) setClient(it)
} }
class Pupil : Application() { class Pupil : Application(), DIAware {
override val di: DI by DI.lazy {
import(androidXModule(this@Pupil))
import(sourceModule)
bind<OkHttpClient>() with provider { client }
bind<ImageCache>() with singleton { ImageCache(this@Pupil) }
bind<DownloadManager>() with singleton { DownloadManager(this@Pupil) }
}
private lateinit var firebaseAnalytics: FirebaseAnalytics private lateinit var firebaseAnalytics: FirebaseAnalytics
@@ -90,24 +91,15 @@ class Pupil : Application() {
else userID else userID
} }
initSources(this)
firebaseAnalytics = Firebase.analytics firebaseAnalytics = Firebase.analytics
FirebaseCrashlytics.getInstance().setUserId(userID) FirebaseCrashlytics.getInstance().setUserId(userID)
initSources(this)
val proxyInfo = getProxyInfo() val proxyInfo = getProxyInfo()
clientBuilder = OkHttpClient.Builder() clientBuilder = OkHttpClient.Builder()
.connectTimeout(0, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.SECONDS)
.proxyInfo(proxyInfo) .proxyInfo(proxyInfo)
.addInterceptor { chain ->
val request = chain.request()
val tag = request.tag() ?: return@addInterceptor 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

@@ -19,33 +19,66 @@
package xyz.quaver.pupil.adapters package xyz.quaver.pupil.adapters
import android.content.Context import android.content.Context
import android.graphics.drawable.Animatable
import android.net.Uri 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 android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.ImageView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
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.backends.pipeline.Fresco
import com.facebook.drawee.controller.BaseControllerListener
import com.facebook.drawee.drawable.ScalingUtils
import com.facebook.drawee.interfaces.DraweeController
import com.facebook.drawee.view.SimpleDraweeView import com.facebook.drawee.view.SimpleDraweeView
import kotlinx.coroutines.CoroutineScope import com.facebook.imagepipeline.image.ImageInfo
import kotlinx.coroutines.Dispatchers import com.github.piasy.biv.loader.ImageLoader
import kotlinx.coroutines.delay import com.github.piasy.biv.view.BigImageView
import kotlinx.coroutines.launch import com.github.piasy.biv.view.ImageShownCallback
import com.github.piasy.biv.view.ImageViewFactory
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.util.downloader.Cache import java.io.File
import xyz.quaver.pupil.util.downloader.Downloader import java.lang.Exception
import kotlin.math.roundToInt import kotlin.math.roundToInt
class ReaderAdapter( data class ReaderItem(
private val context: Context, val progress: Float,
private val source: String, val image: File?
private val itemID: String )
) : RecyclerView.Adapter<ReaderAdapter.ViewHolder>() {
class ReaderAdapter : ListAdapter<ReaderItem, ReaderAdapter.ViewHolder>(ReaderItemDiffCallback()) {
var onItemClickListener : (() -> (Unit))? = null var onItemClickListener : (() -> (Unit))? = null
var fullscreen = false
inner class ViewHolder(private val binding: ReaderItemBinding) : RecyclerView.ViewHolder(binding.root) { inner class ViewHolder(private val binding: ReaderItemBinding) : RecyclerView.ViewHolder(binding.root) {
init { init {
with (binding.image) { with (binding.image) {
setImageViewFactory(FrescoImageViewFactory().apply {
updateView = { imageInfo ->
layoutParams.height = imageInfo.height
(mainView as? SimpleDraweeView)?.aspectRatio = imageInfo.width / imageInfo.height.toFloat()
}
})
setImageShownCallback(object: ImageShownCallback {
override fun onMainImageShown() {
binding.progressGroup.visibility = View.INVISIBLE
binding.root.layoutParams.height = if (fullscreen)
MATCH_PARENT
else
WRAP_CONTENT
}
override fun onThumbnailShown() {}
})
setFailureImage(ContextCompat.getDrawable(itemView.context, R.drawable.image_broken_variant)) setFailureImage(ContextCompat.getDrawable(itemView.context, R.drawable.image_broken_variant))
setOnClickListener { setOnClickListener {
onItemClickListener?.invoke() onItemClickListener?.invoke()
@@ -60,42 +93,35 @@ class ReaderAdapter(
} }
fun bind(position: Int) { fun bind(position: Int) {
recycle()
binding.root.layoutParams.height = MATCH_PARENT
binding.readerIndex.text = (position+1).toString() binding.readerIndex.text = (position+1).toString()
val image = Cache.getInstance(context, source, itemID).getImage(position)?.uri val (progress, image) = getItem(position)
if (image != null) binding.progressGroup.visibility = View.VISIBLE
binding.image.showImage(image)
else { if (image != null) {
val progress = Downloader.getInstance(context).getProgress(source, itemID)?.get(position) ?: 0F binding.root.background = null
binding.image.showImage(Uri.fromFile(image))
} else {
binding.root.setBackgroundResource(R.drawable.reader_item_boundary)
if (progress == Float.NEGATIVE_INFINITY) if (progress == Float.NEGATIVE_INFINITY)
with (binding.image) { binding.image.showImage(Uri.EMPTY)
showImage(Uri.EMPTY) else
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() binding.readerItemProgressbar.progress = progress.roundToInt()
CoroutineScope(Dispatchers.Main).launch {
delay(1000)
notifyItemChanged(position)
}
}
} }
} }
fun clear() { fun recycle() {
binding.image.mainView.let { binding.image.mainView.run {
when (it) { when (this) {
is SubsamplingScaleImageView -> is SubsamplingScaleImageView -> recycle()
it.recycle() is SimpleDraweeView -> recycle()
is SimpleDraweeView -> is ImageView -> setImageBitmap(null)
it.controller = null
} }
} }
} }
@@ -109,10 +135,102 @@ class ReaderAdapter(
holder.bind(position) holder.bind(position)
} }
override fun getItemCount() = Downloader.getInstance(context).getProgress(source, itemID)?.size ?: 0
override fun onViewRecycled(holder: ViewHolder) { override fun onViewRecycled(holder: ViewHolder) {
holder.clear() super.onViewRecycled(holder)
holder.recycle()
} }
}
class ReaderItemDiffCallback : DiffUtil.ItemCallback<ReaderItem>() {
override fun areItemsTheSame(oldItem: ReaderItem, newItem: ReaderItem) =
true
override fun areContentsTheSame(oldItem: ReaderItem, newItem: ReaderItem) =
oldItem == newItem
}
class FrescoImageViewFactory : ImageViewFactory() {
var updateView: ((ImageInfo) -> Unit)? = null
override fun createAnimatedImageView(
context: Context, imageType: Int,
initScaleType: Int
): View {
val view = SimpleDraweeView(context)
view.hierarchy.actualImageScaleType = scaleType(initScaleType)
return view
}
override fun loadAnimatedContent(
view: View, imageType: Int,
imageFile: File
) {
if (view is SimpleDraweeView) {
val controller: DraweeController = Fresco.newDraweeControllerBuilder()
.setOldController(view.controller)
.setUri(Uri.parse("file://" + imageFile.absolutePath))
.setAutoPlayAnimations(true)
.setControllerListener(object: BaseControllerListener<ImageInfo>() {
override fun onIntermediateImageSet(id: String?, imageInfo: ImageInfo?) {
imageInfo?.let { updateView?.invoke(it) }
}
override fun onFinalImageSet(id: String?, imageInfo: ImageInfo?, animatable: Animatable?) {
imageInfo?.let { updateView?.invoke(it) }
}
})
.build()
view.controller = controller
}
}
override fun createThumbnailView(
context: Context,
scaleType: ImageView.ScaleType, willLoadFromNetwork: Boolean
): View {
return if (willLoadFromNetwork) {
val thumbnailView = SimpleDraweeView(context)
thumbnailView.hierarchy.actualImageScaleType = scaleType(scaleType)
thumbnailView
} else {
super.createThumbnailView(context, scaleType, false)
}
}
override fun loadThumbnailContent(view: View, thumbnail: Uri) {
if (view is SimpleDraweeView) {
val controller: DraweeController = Fresco.newDraweeControllerBuilder()
.setOldController(view.controller)
.setUri(thumbnail)
.build()
view.controller = controller
}
}
private fun scaleType(value: Int): ScalingUtils.ScaleType {
return when (value) {
BigImageView.INIT_SCALE_TYPE_CENTER -> ScalingUtils.ScaleType.CENTER
BigImageView.INIT_SCALE_TYPE_CENTER_CROP -> ScalingUtils.ScaleType.CENTER_CROP
BigImageView.INIT_SCALE_TYPE_CENTER_INSIDE -> ScalingUtils.ScaleType.CENTER_INSIDE
BigImageView.INIT_SCALE_TYPE_FIT_END -> ScalingUtils.ScaleType.FIT_END
BigImageView.INIT_SCALE_TYPE_FIT_START -> ScalingUtils.ScaleType.FIT_START
BigImageView.INIT_SCALE_TYPE_FIT_XY -> ScalingUtils.ScaleType.FIT_XY
BigImageView.INIT_SCALE_TYPE_FIT_CENTER -> ScalingUtils.ScaleType.FIT_CENTER
else -> ScalingUtils.ScaleType.FIT_CENTER
}
}
private fun scaleType(scaleType: ImageView.ScaleType): ScalingUtils.ScaleType {
return when (scaleType) {
ImageView.ScaleType.CENTER -> ScalingUtils.ScaleType.CENTER
ImageView.ScaleType.CENTER_CROP -> ScalingUtils.ScaleType.CENTER_CROP
ImageView.ScaleType.CENTER_INSIDE -> ScalingUtils.ScaleType.CENTER_INSIDE
ImageView.ScaleType.FIT_END -> ScalingUtils.ScaleType.FIT_END
ImageView.ScaleType.FIT_START -> ScalingUtils.ScaleType.FIT_START
ImageView.ScaleType.FIT_XY -> ScalingUtils.ScaleType.FIT_XY
ImageView.ScaleType.FIT_CENTER -> ScalingUtils.ScaleType.FIT_CENTER
else -> ScalingUtils.ScaleType.FIT_CENTER
}
}
} }

View File

@@ -39,16 +39,12 @@ import xyz.quaver.pupil.R
import xyz.quaver.pupil.databinding.SearchResultItemBinding import xyz.quaver.pupil.databinding.SearchResultItemBinding
import xyz.quaver.pupil.sources.ItemInfo 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.util.downloader.Cache
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<ItemInfo>) : 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: ((source: String, itemID: String) -> Unit)? = null var onDownloadClickedHandler: ((source: String, itemI: String) -> Unit)? = null
var onDeleteClickedHandler: ((source: String, itemID: String) -> Unit)? = null var onDeleteClickedHandler: ((source: String, itemID: String) -> Unit)? = null
// TODO: migrate to viewBinding // TODO: migrate to viewBinding
@@ -78,11 +74,7 @@ class SearchResultsAdapter(private val results: List<ItemInfo>) : RecyclerSwipeA
override fun onStartOpen(layout: SwipeLayout?) { override fun onStartOpen(layout: SwipeLayout?) {
mItemManger.closeAllExcept(layout) mItemManger.closeAllExcept(layout)
binding.root.binding.download.text = binding.root.binding.download.text = itemView.context.getString(R.string.main_download)
if (Downloader.getInstance(itemView.context).isDownloading(source, itemID))
itemView.context.getString(android.R.string.cancel)
else
itemView.context.getString(R.string.main_download)
} }
override fun onOpen(layout: SwipeLayout?) {} override fun onOpen(layout: SwipeLayout?) {}
@@ -117,8 +109,7 @@ class SearchResultsAdapter(private val results: List<ItemInfo>) : RecyclerSwipeA
} }
private fun updateProgress() { private fun updateProgress() {
val cache = Cache.getInstance(itemView.context, source, itemID) /* TODO
binding.root.max = cache.metadata.imageList?.size ?: 0 binding.root.max = cache.metadata.imageList?.size ?: 0
binding.root.progress = cache.metadata.imageList?.count { it != null } ?: 0 binding.root.progress = cache.metadata.imageList?.count { it != null } ?: 0
@@ -129,6 +120,7 @@ class SearchResultsAdapter(private val results: List<ItemInfo>) : RecyclerSwipeA
ProgressCardView.Type.CACHE ProgressCardView.Type.CACHE
} else } else
ProgressCardView.Type.LOADING ProgressCardView.Type.LOADING
*/
} }
@SuppressLint("SetTextI18n") @SuppressLint("SetTextI18n")

View File

@@ -1,332 +0,0 @@
/*
* 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.services
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.TaskStackBuilder
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Response
import okhttp3.ResponseBody
import okio.*
import xyz.quaver.pupil.*
import xyz.quaver.pupil.ui.ReaderActivity
import xyz.quaver.pupil.util.cleanCache
import xyz.quaver.pupil.util.downloader.Cache
import xyz.quaver.pupil.util.downloader.DownloadManager
import xyz.quaver.pupil.util.ellipsize
import xyz.quaver.pupil.util.normalizeID
import xyz.quaver.pupil.util.requestBuilders
import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
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)
//region Notification
private val notificationManager by lazy {
NotificationManagerCompat.from(this)
}
private val serviceNotification by lazy {
NotificationCompat.Builder(this, "downloader")
.setContentTitle(getString(R.string.downloader_running))
.setProgress(0, 0, false)
.setSmallIcon(R.drawable.ic_notification)
.setOngoing(true)
}
private val notification = ConcurrentHashMap<String, NotificationCompat.Builder?>()
private fun initNotification(galleryID: String) {
val intent = Intent(this, ReaderActivity::class.java)
.putExtra("galleryID", galleryID)
val pendingIntent = TaskStackBuilder.create(this).run {
addNextIntentWithParentStack(intent)
getPendingIntent(galleryID.hashCode(), PendingIntent.FLAG_UPDATE_CURRENT)
}
val action =
NotificationCompat.Action.Builder(0, getText(android.R.string.cancel),
PendingIntent.getService(
this,
R.id.notification_download_cancel_action.normalizeID(),
Intent(this, DownloadService::class.java)
.putExtra(KEY_COMMAND, COMMAND_CANCEL)
.putExtra(KEY_ID, galleryID),
PendingIntent.FLAG_UPDATE_CURRENT),
).build()
notification[galleryID] = NotificationCompat.Builder(this, "download").apply {
setContentTitle(getString(R.string.reader_loading))
setContentText(getString(R.string.reader_notification_text))
setSmallIcon(R.drawable.ic_notification)
setContentIntent(pendingIntent)
addAction(action)
setProgress(0, 0, true)
setOngoing(true)
}
notify(galleryID)
}
@SuppressLint("RestrictedApi")
private fun notify(galleryID: String) {
val max = progress[galleryID]?.size ?: 0
val progress = progress[galleryID]?.count { it == Float.POSITIVE_INFINITY } ?: 0
val notification = notification[galleryID] ?: return
if (isCompleted(galleryID)) {
notification
.setContentText(getString(R.string.reader_notification_complete))
.setProgress(0, 0, false)
.setOngoing(false)
.mActions.clear()
notificationManager.cancel(galleryID.hashCode())
} else
notification
.setProgress(max, progress, false)
.setContentText("$progress/$max")
}
//endregion
//region ProgressListener
@Suppress("UNCHECKED_CAST")
private val progressListener: ProgressListener = { (galleryID, index), bytesRead, contentLength, done ->
if (!done && progress[galleryID]?.get(index)?.isFinite() == true)
progress[galleryID]?.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
//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
*/
val progress = ConcurrentHashMap<String, MutableList<Float>>()
var priority = ""
fun isCompleted(galleryID: String) = progress[galleryID]?.toList()?.all { it == Float.POSITIVE_INFINITY } == true
private val callback = object: Callback {
override fun onFailure(call: Call, e: IOException) {
e.printStackTrace()
if (e.message?.contains("cancel", true) == false) {
val galleryID = (call.request().tag() as Tag).galleryID
// Retry
cancel(galleryID)
download(galleryID)
}
}
override fun onResponse(call: Call, response: Response) {
}
}
fun cancel(startId: Int? = null) {
client.dispatcher().queuedCalls().filter {
it.request().tag() is Tag
}.forEach {
(it.request().tag() as? Tag)?.startId?.let { stopSelf(it) }
it.cancel()
}
client.dispatcher().runningCalls().filter {
it.request().tag() is Tag
}.forEach {
(it.request().tag() as? Tag)?.startId?.let { stopSelf(it) }
it.cancel()
}
progress.clear()
notification.clear()
notificationManager.cancelAll()
startId?.let { stopSelf(it) }
}
fun cancel(galleryID: String, startId: Int? = null) {
client.dispatcher().queuedCalls().filter {
(it.request().tag() as? Tag)?.galleryID == galleryID
}.forEach {
(it.request().tag() as? Tag)?.startId?.let { stopSelf(it) }
it.cancel()
}
client.dispatcher().runningCalls().filter {
(it.request().tag() as? Tag)?.galleryID == galleryID
}.forEach {
(it.request().tag() as? Tag)?.startId?.let { stopSelf(it) }
it.cancel()
}
progress.remove(galleryID)
notification.remove(galleryID)
notificationManager.cancel(galleryID.hashCode())
startId?.let { stopSelf(it) }
}
fun delete(galleryID: String, startId: Int? = null) = CoroutineScope(Dispatchers.IO).launch {
}
fun download(galleryID: String, priority: Boolean = false, startId: Int? = null): Job = CoroutineScope(Dispatchers.IO).launch {
}
//endregion
companion object {
const val KEY_COMMAND = "COMMAND" // String
const val KEY_ID = "ID" // Int
const val KEY_PRIORITY = "PRIORITY" // Boolean
const val COMMAND_DOWNLOAD = "DOWNLOAD"
const val COMMAND_CANCEL = "CANCEL"
const val COMMAND_DELETE = "DELETE"
private fun command(context: Context, extras: Intent.() -> Unit) {
ContextCompat.startForegroundService(context, Intent(context, DownloadService::class.java).apply(extras))
}
fun download(context: Context, galleryID: String, priority: Boolean = false) {
command(context) {
putExtra(KEY_COMMAND, COMMAND_DOWNLOAD)
putExtra(KEY_PRIORITY, priority)
putExtra(KEY_ID, galleryID)
}
}
fun cancel(context: Context, galleryID: String? = null) {
command(context) {
putExtra(KEY_COMMAND, COMMAND_CANCEL)
galleryID?.let { putExtra(KEY_ID, it) }
}
}
fun delete(context: Context, galleryID: String) {
command(context) {
putExtra(KEY_COMMAND, COMMAND_DELETE)
putExtra(KEY_ID, galleryID)
}
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
startForeground(R.id.downloader_notification_id, serviceNotification.build())
when (intent?.getStringExtra(KEY_COMMAND)) {
COMMAND_DOWNLOAD -> intent.getStringExtra(KEY_ID).let { if (!it.isNullOrEmpty())
download(it, intent.getBooleanExtra(KEY_PRIORITY, false), startId)
}
COMMAND_CANCEL -> intent.getStringExtra(KEY_ID).let { if (!it.isNullOrEmpty()) cancel(it, startId) else cancel(startId = startId) }
COMMAND_DELETE -> intent.getStringExtra(KEY_ID).let { if (!it.isNullOrEmpty()) delete(it, startId) }
}
return START_NOT_STICKY
}
inner class Binder : android.os.Binder() {
val service = this@DownloadService
}
private val binder = Binder()
override fun onBind(p0: Intent?) = binder
override fun onCreate() {
startForeground(R.id.downloader_notification_id, serviceNotification.build())
interceptors[Tag::class] = interceptor
}
override fun onDestroy() {
interceptors.remove(Tag::class)
}
}

View File

@@ -29,6 +29,10 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.encoding.Encoder
import org.kodein.di.DI
import org.kodein.di.bind
import org.kodein.di.contexted
import org.kodein.di.instance
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
@@ -110,6 +114,7 @@ enum class DefaultSortMode {
@Parcelize @Parcelize
class DefaultSearchSuggestion(override val body: String) : SearchSuggestion class DefaultSearchSuggestion(override val body: String) : SearchSuggestion
typealias AnySource = Source<*, SearchSuggestion>
abstract class Source<Query_SortMode: Enum<Query_SortMode>, Suggestion: SearchSuggestion> { abstract class Source<Query_SortMode: Enum<Query_SortMode>, Suggestion: SearchSuggestion> {
abstract val name: String abstract val name: String
abstract val iconResID: Int abstract val iconResID: Int
@@ -117,10 +122,10 @@ abstract class Source<Query_SortMode: Enum<Query_SortMode>, Suggestion: SearchSu
abstract suspend fun search(query: String, range: IntRange, sortMode: Enum<*>) : Pair<Channel<ItemInfo>, 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<String> abstract suspend fun images(itemID: String) : List<String>
abstract suspend fun info(id: String) : ItemInfo abstract suspend fun info(itemID: String) : ItemInfo
open fun getHeadersForImage(id: String, url: String): Map<String, String> { open fun getHeadersForImage(itemID: String, url: String): Map<String, String> {
return emptyMap() return emptyMap()
} }
@@ -129,9 +134,21 @@ abstract class Source<Query_SortMode: Enum<Query_SortMode>, Suggestion: SearchSu
} }
} }
val sources = mutableMapOf<String, Source<*, SearchSuggestion>>() @Deprecated("")
val sources = mutableMapOf<String, AnySource>()
val sourceIcons = mutableMapOf<String, Drawable?>() val sourceIcons = mutableMapOf<String, Drawable?>()
@Suppress("UNCHECKED_CAST")
val sourceModule = DI.Module(name = "source") {
listOf(
Hitomi(),
Hiyobi()
).forEach {
bind<AnySource>(tag = it.name) with instance (it as AnySource)
}
}
@Deprecated("")
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
fun initSources(context: Context) { fun initSources(context: Context) {
// Add Default Sources // Add Default Sources
@@ -139,7 +156,7 @@ fun initSources(context: Context) {
Hitomi(), Hitomi(),
Hiyobi() Hiyobi()
).forEach { ).forEach {
sources[it.name] = it as Source<*, SearchSuggestion> sources[it.name] = it as AnySource
sourceIcons[it.name] = ContextCompat.getDrawable(context, it.iconResID) sourceIcons[it.name] = ContextCompat.getDrawable(context, it.iconResID)
} }
} }

View File

@@ -98,8 +98,8 @@ class Hitomi : Source<Hitomi.SortMode, Hitomi.TagSuggestion>() {
} }
} }
override suspend fun images(id: String): List<String> { override suspend fun images(itemID: String): List<String> {
val galleryID = id.toInt() val galleryID = itemID.toInt()
val reader = getGalleryInfo(galleryID) val reader = getGalleryInfo(galleryID)
@@ -108,32 +108,36 @@ class Hitomi : Source<Hitomi.SortMode, Hitomi.TagSuggestion>() {
} }
} }
override suspend fun info(id: String): ItemInfo = coroutineScope { override suspend fun info(itemID: String): ItemInfo = coroutineScope {
getGallery(id.toInt()).let { kotlin.runCatching {
ItemInfo( getGallery(itemID.toInt()).let {
name, ItemInfo(
id, name,
it.title, itemID,
it.cover, it.title,
it.artists.joinToString { it.wordCapitalize() }, it.cover,
mapOf( it.artists.joinToString { it.wordCapitalize() },
ExtraType.TYPE to async { it.type.wordCapitalize() }, mapOf(
ExtraType.GROUP to async { it.groups.joinToString { it.wordCapitalize() } }, ExtraType.TYPE to async { it.type.wordCapitalize() },
ExtraType.LANGUAGE to async { languageMap[it.language] ?: it.language }, ExtraType.GROUP to async { it.groups.joinToString { it.wordCapitalize() } },
ExtraType.SERIES to async { it.series.joinToString { it.wordCapitalize() } }, ExtraType.LANGUAGE to async { languageMap[it.language] ?: it.language },
ExtraType.CHARACTER to async { it.characters.joinToString { it.wordCapitalize() } }, ExtraType.SERIES to async { it.series.joinToString { it.wordCapitalize() } },
ExtraType.TAGS to async { it.tags.joinToString() }, ExtraType.CHARACTER to async { it.characters.joinToString { it.wordCapitalize() } },
ExtraType.PREVIEW to async { it.thumbnails.joinToString() }, ExtraType.TAGS to async { it.tags.joinToString() },
ExtraType.RELATED_ITEM to async { it.related.joinToString() }, ExtraType.PREVIEW to async { it.thumbnails.joinToString() },
ExtraType.PAGECOUNT to async { it.thumbnails.size.toString() }, ExtraType.RELATED_ITEM to async { it.related.joinToString() },
ExtraType.PAGECOUNT to async { it.thumbnails.size.toString() },
)
) )
) }
}.getOrElse {
transform(name, getGalleryBlock(itemID.toInt()))
} }
} }
override fun getHeadersForImage(id: String, url: String): Map<String, String> { override fun getHeadersForImage(itemID: String, url: String): Map<String, String> {
return mapOf( return mapOf(
"Referer" to getReferer(id.toInt()) "Referer" to getReferer(itemID.toInt())
) )
} }

View File

@@ -72,14 +72,14 @@ class Hiyobi : Source<DefaultSortMode, DefaultSearchSuggestion>() {
return result.map { DefaultSearchSuggestion(it) } return result.map { DefaultSearchSuggestion(it) }
} }
override suspend fun images(id: String): List<String> { override suspend fun images(itemID: String): List<String> {
return createImgList(id, getGalleryInfo(id), true).map { return createImgList(itemID, getGalleryInfo(itemID), false).map {
it.path it.path
} }
} }
override suspend fun info(id: String): ItemInfo { override suspend fun info(itemID: String): ItemInfo {
return transform(name, getGalleryBlock(id)) return transform(name, getGalleryBlock(itemID))
} }
override fun onSuggestionBind(binding: SearchSuggestionItemBinding, item: DefaultSearchSuggestion) { override fun onSuggestionBind(binding: SearchSuggestionItemBinding, item: DefaultSearchSuggestion) {
@@ -105,7 +105,7 @@ class Hiyobi : Source<DefaultSortMode, DefaultSearchSuggestion>() {
} }
companion object { companion object {
private fun downloadAllTags(): Deferred<List<String>> = CoroutineScope(Dispatchers.IO).async { private fun downloadAllTagsAsync(): Deferred<List<String>> = CoroutineScope(Dispatchers.IO).async {
Json.decodeFromString(kotlin.runCatching { Json.decodeFromString(kotlin.runCatching {
client.newCall(Request.Builder().url("https://api.hiyobi.me/auto.json").build()).execute().also { if (it.code() != 200) throw IOException() }.body()?.use { it.string() } client.newCall(Request.Builder().url("https://api.hiyobi.me/auto.json").build()).execute().also { if (it.code() != 200) throw IOException() }.body()?.use { it.string() }
}.getOrNull() ?: "[]") }.getOrNull() ?: "[]")
@@ -114,7 +114,7 @@ class Hiyobi : Source<DefaultSortMode, DefaultSearchSuggestion>() {
private var _allTags: Deferred<List<String>>? = null private var _allTags: Deferred<List<String>>? = null
val allTags: Deferred<List<String>> val allTags: Deferred<List<String>>
get() = if (_allTags == null || (_allTags!!.isCompleted && runBlocking { _allTags!!.await() }.isEmpty())) downloadAllTags().also { get() = if (_allTags == null || (_allTags!!.isCompleted && runBlocking { _allTags!!.await() }.isEmpty())) downloadAllTagsAsync().also {
_allTags = it _allTags = it
} else _allTags!! } else _allTags!!

View File

@@ -50,7 +50,6 @@ import xyz.quaver.floatingsearchview.util.view.SearchInputView
import xyz.quaver.pupil.* 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.sources.ItemInfo 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
@@ -62,9 +61,6 @@ 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.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
@@ -223,7 +219,7 @@ class MainActivity :
with (binding.contents.cancelFab) { with (binding.contents.cancelFab) {
setImageResource(R.drawable.cancel) setImageResource(R.drawable.cancel)
setOnClickListener { setOnClickListener {
Downloader.getInstance(context).cancel()
} }
} }
@@ -354,22 +350,12 @@ class MainActivity :
query() query()
} }
onDownloadClickedHandler = { source, itemID -> onDownloadClickedHandler = { source, itemID ->
if (Downloader.getInstance(context).isDownloading(source, itemID)) { //download in progress
Downloader.getInstance(context).cancel(source, itemID)
}
else {
DownloadManager.getInstance(context).addDownloadFolder(source, itemID)
Downloader.getInstance(context).download(source, itemID)
}
closeAllItems() closeAllItems()
} }
onDeleteClickedHandler = { source, itemID -> onDeleteClickedHandler = { source, itemID ->
Downloader.getInstance(context).cancel(source, itemID)
Cache.delete(source, itemID)
histories.remove(itemID)
closeAllItems() closeAllItems()
} }

View File

@@ -22,24 +22,30 @@ import android.content.Intent
import android.graphics.drawable.Animatable import android.graphics.drawable.Animatable
import android.os.Bundle import android.os.Bundle
import android.view.* import android.view.*
import androidx.appcompat.app.AlertDialog import androidx.activity.viewModels
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.forEach
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.AnimatedVectorDrawableCompat import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
import com.google.firebase.crashlytics.FirebaseCrashlytics import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.qtalk.recyclerviewfastscroller.RecyclerViewFastScroller import com.qtalk.recyclerviewfastscroller.RecyclerViewFastScroller
import org.kodein.di.DIAware
import org.kodein.di.android.di
import org.kodein.di.direct
import org.kodein.di.instance
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.ReaderActivityBinding 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.sources.AnySource
import xyz.quaver.pupil.ui.viewmodel.ReaderViewModel
import xyz.quaver.pupil.util.Preferences import xyz.quaver.pupil.util.Preferences
import xyz.quaver.pupil.util.downloader.Downloader
class ReaderActivity : BaseActivity() { class ReaderActivity : BaseActivity(), DIAware {
override val di by di()
private var source = "" private var source = ""
private var itemID = "" private var itemID = ""
@@ -50,14 +56,13 @@ class ReaderActivity : BaseActivity() {
private var isFullscreen = false private var isFullscreen = false
set(value) { set(value) {
field = value field = value
//(binding.recyclerview.adapter as ReaderAdapter).isFullScreen = value
} }
private val snapHelper = PagerSnapHelper() private val snapHelper = PagerSnapHelper()
private var menu: Menu? = null private var menu: Menu? = null
private lateinit var binding: ReaderActivityBinding private lateinit var binding: ReaderActivityBinding
private val model: ReaderViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -68,36 +73,34 @@ class ReaderActivity : BaseActivity() {
supportActionBar?.setDisplayHomeAsUpEnabled(false) supportActionBar?.setDisplayHomeAsUpEnabled(false)
handleIntent(intent) handleIntent(intent)
FirebaseCrashlytics.getInstance().setCustomKey("GalleryID", itemID)
if (itemID.isEmpty()) { if (itemID.isEmpty()) {
onBackPressed() onBackPressed()
return return
} }
with (Downloader.getInstance(this)) { FirebaseCrashlytics.getInstance().setCustomKey("GalleryID", itemID)
onImageListLoadedCallback = {
runOnUiThread {
binding.recyclerview.adapter?.notifyDataSetChanged()
}
}
download(source, itemID)
}
binding.recyclerview.adapter = ReaderAdapter(this, source, itemID).apply { model.readerItems.observe(this) {
onItemClickListener = { (binding.recyclerview.adapter as ReaderAdapter).submitList(it.toMutableList())
if (isScroll) {
isScroll = false
isFullscreen = true
scrollMode(false) binding.downloadProgressbar.apply {
fullscreen(true) max = it.size
} else { progress = it.count { it.image != null }
(binding.recyclerview.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(currentPage, 0) //Moves to next page because currentPage is 1-based indexing
} visibility =
if (progress == max)
View.GONE
else
View.VISIBLE
} }
} }
model.title.observe(this) {
title = it
}
model.load(source, itemID)
initView() initView()
} }
@@ -129,11 +132,16 @@ class ReaderActivity : BaseActivity() {
override fun onCreateOptionsMenu(menu: Menu?): Boolean { override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.reader, menu) menuInflater.inflate(R.menu.reader, menu)
with (menu?.findItem(R.id.reader_menu_favorite)) { menu?.forEach {
this ?: return@with when (it.itemId) {
R.id.reader_menu_favorite -> {
if (favorites.contains(itemID)) if (favorites.contains(itemID))
(icon as Animatable).start() (it.icon as Animatable).start()
}
R.id.source -> {
it.setIcon(direct.instance<AnySource>(tag = source).iconResID)
}
}
} }
this.menu = menu this.menu = menu
@@ -142,25 +150,6 @@ class ReaderActivity : BaseActivity() {
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
when(item.itemId) { when(item.itemId) {
R.id.reader_menu_page_indicator -> {
// TODO: Switch to DialogFragment
val binding = NumberpickerDialogBinding.inflate(layoutInflater, binding.root, false)
with (binding.numberPicker) {
minValue = 1
maxValue = this@ReaderActivity.binding.recyclerview.adapter?.itemCount ?: 0
value = currentPage
}
val dialog = AlertDialog.Builder(this).apply {
setView(binding.root)
}.create()
binding.okButton.setOnClickListener {
(this@ReaderActivity.binding.recyclerview.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(binding.numberPicker.value-1, 0)
dialog.dismiss()
}
dialog.show()
}
R.id.reader_menu_favorite -> { R.id.reader_menu_favorite -> {
val id = itemID 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
@@ -194,15 +183,14 @@ class ReaderActivity : BaseActivity() {
} }
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
//currentPage is 1-based
return when(keyCode) { return when(keyCode) {
KeyEvent.KEYCODE_VOLUME_UP -> { KeyEvent.KEYCODE_VOLUME_UP -> {
(binding.recyclerview.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(currentPage-2, 0) (binding.recyclerview.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(currentPage-1, 0)
true true
} }
KeyEvent.KEYCODE_VOLUME_DOWN -> { KeyEvent.KEYCODE_VOLUME_DOWN -> {
(binding.recyclerview.layoutManager as LinearLayoutManager?)?.scrollToPositionWithOffset(currentPage, 0) (binding.recyclerview.layoutManager as LinearLayoutManager?)?.scrollToPositionWithOffset(currentPage+1, 0)
true true
} }
@@ -212,6 +200,20 @@ class ReaderActivity : BaseActivity() {
private fun initView() { private fun initView() {
with (binding.recyclerview) { with (binding.recyclerview) {
adapter = ReaderAdapter().apply {
onItemClickListener = {
if (isScroll) {
isScroll = false
isFullscreen = true
scrollMode(false)
fullscreen(true)
} else {
binding.recyclerview.layoutManager?.scrollToPosition(currentPage+1) // 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)
@@ -225,16 +227,19 @@ class ReaderActivity : BaseActivity() {
if (layoutManager.findFirstVisibleItemPosition() == -1) if (layoutManager.findFirstVisibleItemPosition() == -1)
return return
currentPage = layoutManager.findFirstVisibleItemPosition()+1
menu?.findItem(R.id.reader_menu_page_indicator)?.title = "$currentPage/${recyclerView.adapter!!.itemCount}" currentPage = layoutManager.findFirstVisibleItemPosition()
menu?.findItem(R.id.reader_menu_page_indicator)?.title = "${currentPage+1}/${recyclerView.adapter!!.itemCount}"
} }
}) })
itemAnimator = null
} }
with (binding.retryFab) { with (binding.retryFab) {
setImageResource(R.drawable.refresh) setImageResource(R.drawable.refresh)
setOnClickListener { setOnClickListener {
DownloadService.download(context, itemID)
} }
} }
@@ -250,6 +255,8 @@ class ReaderActivity : BaseActivity() {
} }
private fun fullscreen(isFullscreen: Boolean) { private fun fullscreen(isFullscreen: Boolean) {
(binding.recyclerview.adapter as ReaderAdapter).fullscreen = isFullscreen
with (window.attributes) { with (window.attributes) {
if (isFullscreen) { if (isFullscreen) {
flags = flags or WindowManager.LayoutParams.FLAG_FULLSCREEN flags = flags or WindowManager.LayoutParams.FLAG_FULLSCREEN
@@ -282,20 +289,17 @@ 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 = object: LinearLayoutManager(this) { binding.recyclerview.layoutManager = 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]) {
override fun calculateExtraLayoutSpace(state: RecyclerView.State, extraLayoutSpace: IntArray) { override fun calculateExtraLayoutSpace(state: RecyclerView.State, extraLayoutSpace: IntArray) {
extraLayoutSpace.fill(600) extraLayoutSpace[0] = 10
extraLayoutSpace[1] = 10
} }
} }
} }
(binding.recyclerview.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(currentPage-1, 0) (binding.recyclerview.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(currentPage, 0)
} }
} }

View File

@@ -31,6 +31,9 @@ import androidx.fragment.app.DialogFragment
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import net.rdrei.android.dirchooser.DirectoryChooserActivity import net.rdrei.android.dirchooser.DirectoryChooserActivity
import net.rdrei.android.dirchooser.DirectoryChooserConfig import net.rdrei.android.dirchooser.DirectoryChooserConfig
import org.kodein.di.DIAware
import org.kodein.di.android.x.di
import org.kodein.di.instance
import xyz.quaver.io.FileX import xyz.quaver.io.FileX
import xyz.quaver.io.util.toFile import xyz.quaver.io.util.toFile
import xyz.quaver.pupil.R import xyz.quaver.pupil.R
@@ -38,10 +41,14 @@ import xyz.quaver.pupil.databinding.DownloadLocationDialogBinding
import xyz.quaver.pupil.databinding.DownloadLocationItemBinding import xyz.quaver.pupil.databinding.DownloadLocationItemBinding
import xyz.quaver.pupil.util.Preferences import xyz.quaver.pupil.util.Preferences
import xyz.quaver.pupil.util.byteToString import xyz.quaver.pupil.util.byteToString
import xyz.quaver.pupil.util.downloader.DownloadManager import xyz.quaver.pupil.util.DownloadManager
import java.io.File import java.io.File
class DownloadLocationDialogFragment : DialogFragment() { class DownloadLocationDialogFragment : DialogFragment(), DIAware {
override val di by di()
private val downloadManager: DownloadManager by instance()
private var _binding: DownloadLocationDialogBinding? = null private var _binding: DownloadLocationDialogBinding? = null
private val binding get() = _binding!! private val binding get() = _binding!!
@@ -69,7 +76,7 @@ class DownloadLocationDialogFragment : DialogFragment() {
Snackbar.LENGTH_LONG Snackbar.LENGTH_LONG
).show() ).show()
val downloadFolder = DownloadManager.getInstance(context).downloadFolder.canonicalPath val downloadFolder = downloadManager.downloadFolder.canonicalPath
val key = entries.keys.firstOrNull { it?.canonicalPath == downloadFolder } val key = entries.keys.firstOrNull { it?.canonicalPath == downloadFolder }
entries[key]!!.button.isChecked = true entries[key]!!.button.isChecked = true
if (key == null) entries[key]!!.locationAvailable.text = downloadFolder if (key == null) entries[key]!!.locationAvailable.text = downloadFolder
@@ -92,7 +99,7 @@ class DownloadLocationDialogFragment : DialogFragment() {
Snackbar.LENGTH_LONG Snackbar.LENGTH_LONG
).show() ).show()
val downloadFolder = DownloadManager.getInstance(context).downloadFolder.canonicalPath val downloadFolder = downloadManager.downloadFolder.canonicalPath
val key = entries.keys.firstOrNull { it?.canonicalPath == downloadFolder } val key = entries.keys.firstOrNull { it?.canonicalPath == downloadFolder }
entries[key]!!.button.isChecked = true entries[key]!!.button.isChecked = true
if (key == null) entries[key]!!.locationAvailable.text = downloadFolder if (key == null) entries[key]!!.locationAvailable.text = downloadFolder
@@ -165,7 +172,7 @@ class DownloadLocationDialogFragment : DialogFragment() {
entries[null] = this entries[null] = this
} }
val downloadFolder = DownloadManager.getInstance(requireContext()).downloadFolder.canonicalPath val downloadFolder = downloadManager.downloadFolder.canonicalPath
val key = entries.keys.firstOrNull { it?.canonicalPath == downloadFolder } val key = entries.keys.firstOrNull { it?.canonicalPath == downloadFolder }
entries[key]!!.button.isChecked = true entries[key]!!.button.isChecked = true
if (key == null) entries[key]!!.locationAvailable.text = downloadFolder if (key == null) entries[key]!!.locationAvailable.text = downloadFolder

View File

@@ -42,13 +42,11 @@ import xyz.quaver.pupil.adapters.SearchResultsAdapter
import xyz.quaver.pupil.adapters.ThumbnailPageAdapter 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.ItemInfo 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
import xyz.quaver.pupil.util.ItemClickSupport import xyz.quaver.pupil.util.ItemClickSupport
import xyz.quaver.pupil.util.downloader.Cache
import xyz.quaver.pupil.util.wordCapitalize import xyz.quaver.pupil.util.wordCapitalize
import java.util.* import java.util.*
import kotlin.collections.ArrayList import kotlin.collections.ArrayList

View File

@@ -1,91 +0,0 @@
/*
* 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.ui.dialog
import android.annotation.SuppressLint
import android.app.Dialog
import android.content.Context
import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AlertDialog
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import xyz.quaver.pupil.R
import xyz.quaver.pupil.adapters.MirrorAdapter
import xyz.quaver.pupil.util.Preferences
class MirrorDialog(context: Context) : AlertDialog(context) {
class ItemTouchHelperCallback : ItemTouchHelper.Callback() {
var onMoveItem : ((Int, Int) -> (Unit))? = null
override fun getMovementFlags(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder
) = makeMovementFlags(ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0)
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
onMoveItem?.invoke(viewHolder.adapterPosition, target.adapterPosition)
return true
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
}
}
@SuppressLint("InflateParams")
override fun onCreate(savedInstanceState: Bundle?) {
setTitle(R.string.settings_mirror_title)
setView(build())
setButton(Dialog.BUTTON_POSITIVE, context.getString(android.R.string.ok)) { _, _ -> }
super.onCreate(savedInstanceState)
}
private fun build() : View {
return RecyclerView(context).apply recyclerview@{
addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
layoutManager = LinearLayoutManager(context)
adapter = MirrorAdapter(context).apply adapter@{
val itemTouchHelper = ItemTouchHelper(ItemTouchHelperCallback().apply {
onMoveItem = this@adapter.onItemMove
}).apply {
attachToRecyclerView(this@recyclerview)
}
onStartDrag = {
itemTouchHelper.startDrag(it)
}
onItemMoved = {
Preferences["mirrors"] = it.joinToString(">")
}
}
}
}
}

View File

@@ -22,23 +22,27 @@ import android.os.Bundle
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.preference.Preference import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers import org.kodein.di.DIAware
import kotlinx.coroutines.Job import org.kodein.di.android.x.di
import kotlinx.coroutines.launch import org.kodein.di.instance
import xyz.quaver.io.FileX import xyz.quaver.io.FileX
import xyz.quaver.io.util.deleteRecursively
import xyz.quaver.pupil.R import xyz.quaver.pupil.R
import xyz.quaver.pupil.histories import xyz.quaver.pupil.histories
import xyz.quaver.pupil.util.DownloadManager
import xyz.quaver.pupil.util.ImageCache
import xyz.quaver.pupil.util.byteToString import xyz.quaver.pupil.util.byteToString
import xyz.quaver.pupil.util.downloader.Cache import xyz.quaver.pupil.util.size
import xyz.quaver.pupil.util.downloader.DownloadManager
import java.io.File
class ManageStorageFragment : PreferenceFragmentCompat(), Preference.OnPreferenceClickListener { class ManageStorageFragment : PreferenceFragmentCompat(), DIAware, Preference.OnPreferenceClickListener {
override val di by di()
private var job: Job? = null private var job: Job? = null
private val downloadManager: DownloadManager by instance()
private val cache: ImageCache by instance()
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.manage_storage_preferences, rootKey) setPreferencesFromResource(R.xml.manage_storage_preferences, rootKey)
@@ -53,27 +57,19 @@ class ManageStorageFragment : PreferenceFragmentCompat(), Preference.OnPreferenc
when (key) { when (key) {
"delete_cache" -> { "delete_cache" -> {
val dir = File(context.cacheDir, "imageCache") val cache: ImageCache by instance()
AlertDialog.Builder(context).apply { AlertDialog.Builder(context).apply {
setTitle(R.string.warning) setTitle(R.string.warning)
setMessage(R.string.settings_clear_cache_alert_message) setMessage(R.string.settings_clear_cache_alert_message)
setPositiveButton(android.R.string.ok) { _, _ -> setPositiveButton(android.R.string.ok) { _, _ ->
if (dir.exists()) summary = context.getString(R.string.settings_storage_usage_loading)
dir.deleteRecursively()
Cache.instances.clear()
summary = context.getString(R.string.settings_storage_usage, byteToString(0))
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
var size = 0L cache.clear()
dir.walk().forEach { MainScope().launch {
size += it.length() summary = context.getString(R.string.settings_storage_usage, byteToString(cache.cacheFolder.size()))
launch(Dispatchers.Main) {
summary = context.getString(R.string.settings_storage_usage, byteToString(size))
}
} }
} }
} }
@@ -81,7 +77,7 @@ class ManageStorageFragment : PreferenceFragmentCompat(), Preference.OnPreferenc
}.show() }.show()
} }
"delete_downloads" -> { "delete_downloads" -> {
val dir = DownloadManager.getInstance(context).downloadFolder val dir = downloadManager.downloadFolder
AlertDialog.Builder(context).apply { AlertDialog.Builder(context).apply {
setTitle(R.string.warning) setTitle(R.string.warning)
@@ -143,21 +139,7 @@ class ManageStorageFragment : PreferenceFragmentCompat(), Preference.OnPreferenc
with (findPreference<Preference>("delete_cache")) { with (findPreference<Preference>("delete_cache")) {
this ?: return@with this ?: return@with
summary = context.getString(R.string.settings_storage_usage, byteToString(cache.cacheFolder.size()))
val dir = File(context.cacheDir, "imageCache")
summary = context.getString(R.string.settings_storage_usage, byteToString(0))
CoroutineScope(Dispatchers.IO).launch {
var size = 0L
dir.walk().forEach {
size += it.length()
launch(Dispatchers.Main) {
summary = context.getString(R.string.settings_storage_usage, byteToString(size))
}
}
}
onPreferenceClickListener = this@ManageStorageFragment onPreferenceClickListener = this@ManageStorageFragment
} }
@@ -165,7 +147,7 @@ class ManageStorageFragment : PreferenceFragmentCompat(), Preference.OnPreferenc
with (findPreference<Preference>("delete_downloads")) { with (findPreference<Preference>("delete_downloads")) {
this ?: return@with this ?: return@with
val dir = DownloadManager.getInstance(context).downloadFolder val dir = downloadManager.downloadFolder
summary = context.getString(R.string.settings_storage_usage, byteToString(0)) summary = context.getString(R.string.settings_storage_usage, byteToString(0))
job?.cancel() job?.cancel()

View File

@@ -29,6 +29,9 @@ import com.google.android.gms.oss.licenses.OssLicensesMenuActivity
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.kodein.di.DIAware
import org.kodein.di.android.x.di
import org.kodein.di.instance
import xyz.quaver.io.FileX import xyz.quaver.io.FileX
import xyz.quaver.io.util.getChild import xyz.quaver.io.util.getChild
import xyz.quaver.pupil.R import xyz.quaver.pupil.R
@@ -36,14 +39,19 @@ import xyz.quaver.pupil.ui.LockActivity
import xyz.quaver.pupil.ui.SettingsActivity import xyz.quaver.pupil.ui.SettingsActivity
import xyz.quaver.pupil.ui.dialog.* import xyz.quaver.pupil.ui.dialog.*
import xyz.quaver.pupil.util.* import xyz.quaver.pupil.util.*
import xyz.quaver.pupil.util.downloader.DownloadManager import xyz.quaver.pupil.util.DownloadManager
import java.util.* import java.util.*
class SettingsFragment : class SettingsFragment :
PreferenceFragmentCompat(), PreferenceFragmentCompat(),
Preference.OnPreferenceClickListener, Preference.OnPreferenceClickListener,
Preference.OnPreferenceChangeListener, Preference.OnPreferenceChangeListener,
SharedPreferences.OnSharedPreferenceChangeListener { SharedPreferences.OnSharedPreferenceChangeListener,
DIAware {
override val di by di()
private val downloadManager: DownloadManager by instance()
private val lockLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { private val lockLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK) { if (it.resultCode == Activity.RESULT_OK) {
@@ -98,10 +106,6 @@ class SettingsFragment :
} }
lockLauncher.launch(intent) lockLauncher.launch(intent)
} }
"mirrors" -> {
MirrorDialog(requireContext())
.show()
}
"proxy" -> { "proxy" -> {
ProxyDialog(requireContext()) ProxyDialog(requireContext())
.show() .show()
@@ -131,7 +135,7 @@ class SettingsFragment :
val create = (newValue as? Boolean) ?: return false val create = (newValue as? Boolean) ?: return false
return kotlin.runCatching { return kotlin.runCatching {
val nomedia = DownloadManager.getInstance(context).downloadFolder.getChild(".nomedia") val nomedia = downloadManager.downloadFolder.getChild(".nomedia")
if (create) if (create)
nomedia.createNewFile() nomedia.createNewFile()
@@ -220,7 +224,7 @@ class SettingsFragment :
} }
"nomedia" -> { "nomedia" -> {
(this as SwitchPreferenceCompat).isChecked = kotlin.runCatching { (this as SwitchPreferenceCompat).isChecked = kotlin.runCatching {
DownloadManager.getInstance(context).downloadFolder.getChild(".nomedia").exists() downloadManager.downloadFolder.getChild(".nomedia").exists()
}.getOrDefault(false) }.getOrDefault(false)
onPreferenceChangeListener = this@SettingsFragment onPreferenceChangeListener = this@SettingsFragment
@@ -268,9 +272,6 @@ class SettingsFragment :
onPreferenceChangeListener = this@SettingsFragment onPreferenceChangeListener = this@SettingsFragment
} }
"mirrors" -> {
onPreferenceClickListener = this@SettingsFragment
}
"proxy" -> { "proxy" -> {
summary = getProxyInfo().type.name summary = getProxyInfo().type.name

View File

@@ -0,0 +1,112 @@
/*
* 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.ui.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.consumeAsFlow
import okhttp3.Headers
import okhttp3.OkHttpClient
import okhttp3.Request
import org.kodein.di.DIAware
import org.kodein.di.android.x.di
import org.kodein.di.direct
import org.kodein.di.instance
import xyz.quaver.pupil.adapters.ReaderItem
import xyz.quaver.pupil.sources.AnySource
import xyz.quaver.pupil.util.ImageCache
import xyz.quaver.pupil.util.notify
@Suppress("UNCHECKED_CAST")
class ReaderViewModel(app: Application) : AndroidViewModel(app), DIAware {
override val di by di()
private val cache: ImageCache by instance()
private val _title = MutableLiveData<String>()
val title = _title as LiveData<String>
private val _images = MutableLiveData<List<String>>()
val images: LiveData<List<String>> = _images
private var _readerItems = MutableLiveData<MutableList<ReaderItem>>()
val readerItems = _readerItems as LiveData<List<ReaderItem>>
@OptIn(ExperimentalCoroutinesApi::class)
fun load(sourceName: String, itemID: String) {
val source: AnySource by instance(tag = sourceName)
viewModelScope.launch {
_title.value = withContext(Dispatchers.IO) {
source.info(itemID)
}.title
}
viewModelScope.launch {
withContext(Dispatchers.IO) {
source.images(itemID)
}.let { images ->
_readerItems.value = MutableList(images.size) { ReaderItem(0F, null) }
_images.value = images
images.forEachIndexed { index, image ->
val file = cache.load(
Request.Builder()
.url(image)
.headers(Headers.of(source.getHeadersForImage(itemID, image)))
.build()
)
val channel = cache.channels[image] ?: error("Channel is null")
channel.invokeOnClose { e ->
viewModelScope.launch {
if (e == null) {
_readerItems.value!![index] = ReaderItem(_readerItems.value!![index].progress, file)
_readerItems.notify()
}
}
}
launch {
for (progress in channel) {
_readerItems.value!![index] = ReaderItem(progress, _readerItems.value!![index].image)
_readerItems.notify()
}
}
}
}
}
}
override fun onCleared() {
CoroutineScope(Dispatchers.IO).launch {
cache.cleanup()
images.value?.let { cache.free(it) }
}
}
}

View File

@@ -1,6 +1,6 @@
/* /*
* Pupil, Hitomi.la viewer for Android * Pupil, Hitomi.la viewer for Android
* Copyright (C) 2020 tom5079 * Copyright (C) 2021 tom5079
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@@ -16,35 +16,29 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
package xyz.quaver.pupil.util.downloader package xyz.quaver.pupil.util
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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.launch 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 org.kodein.di.DIAware
import org.kodein.di.android.di
import org.kodein.di.instance
import xyz.quaver.io.FileX import xyz.quaver.io.FileX
import xyz.quaver.io.util.* import xyz.quaver.io.util.*
import xyz.quaver.pupil.sources.sources import xyz.quaver.pupil.sources.AnySource
import xyz.quaver.pupil.util.Preferences
import xyz.quaver.pupil.util.formatDownloadFolder
class DownloadManager private constructor(context: Context) : ContextWrapper(context) { class DownloadManager constructor(context: Context) : ContextWrapper(context), DIAware {
companion object { override val di by di(context)
@Volatile private var instance: DownloadManager? = null
fun getInstance(context: Context) = private val defaultDownloadFolder = FileX(this, getExternalFilesDir(null)!!)
instance ?: synchronized(this) {
instance ?: DownloadManager(context).also { instance = it }
}
}
val defaultDownloadFolder = FileX(this, getExternalFilesDir(null)!!)
val downloadFolder: FileX val downloadFolder: FileX
get() = { get() = {
@@ -58,7 +52,7 @@ class DownloadManager private constructor(context: Context) : ContextWrapper(con
private var prevDownloadFolder: FileX? = null private var prevDownloadFolder: FileX? = null
private var downloadFolderMapInstance: MutableMap<String, String>? = null private var downloadFolderMapInstance: MutableMap<String, String>? = null
val downloadFolderMap: MutableMap<String, String> private val downloadFolderMap: MutableMap<String, String>
@Synchronized @Synchronized
get() { get() {
if (prevDownloadFolder != downloadFolder) { if (prevDownloadFolder != downloadFolder) {
@@ -88,8 +82,12 @@ class DownloadManager private constructor(context: Context) : ContextWrapper(con
downloadFolderMap["$source-$itemID"]?.let { downloadFolder.getChild(it) } downloadFolderMap["$source-$itemID"]?.let { downloadFolder.getChild(it) }
@Synchronized @Synchronized
fun addDownloadFolder(source: String, itemID: String) = CoroutineScope(Dispatchers.IO).launch { fun download(source: String, itemID: String) = CoroutineScope(Dispatchers.IO).launch {
val name = "A" // TODO val source: AnySource by instance(tag = source)
val info = async { source.info(itemID) }
val images = async { source.images(itemID) }
val name = info.await().formatDownloadFolder()
val folder = downloadFolder.getChild("$source/$name") val folder = downloadFolder.getChild("$source/$name")
@@ -105,7 +103,7 @@ class DownloadManager private constructor(context: Context) : ContextWrapper(con
} }
@Synchronized @Synchronized
fun deleteDownloadFolder(source: String, itemID: String) { fun delete(source: String, itemID: String) {
downloadFolderMap["$source/$itemID"]?.let { downloadFolderMap["$source/$itemID"]?.let {
kotlin.runCatching { kotlin.runCatching {
downloadFolder.getChild(it).deleteRecursively() downloadFolder.getChild(it).deleteRecursively()

View File

@@ -0,0 +1,126 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2021 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
import android.content.Context
import com.google.firebase.crashlytics.FirebaseCrashlytics
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.sendBlocking
import okhttp3.*
import org.kodein.di.DIAware
import org.kodein.di.android.di
import org.kodein.di.instance
import java.io.File
import java.io.IOException
import java.util.*
import java.util.concurrent.ConcurrentHashMap
class ImageCache(context: Context) : DIAware {
override val di by di(context)
private val client: OkHttpClient by instance()
val cacheFolder = File(context.cacheDir, "imageCache")
val cache = SavedMap(File(cacheFolder, ".cache"), "", "")
private val _channels = ConcurrentHashMap<String, Channel<Float>>()
val channels = _channels as Map<String, Channel<Float>>
@Synchronized
@OptIn(ExperimentalCoroutinesApi::class)
suspend fun cleanup() = coroutineScope {
val LIMIT = 100*1024*1024
cacheFolder.listFiles { it -> it.canonicalPath !in cache }?.forEach { it.delete() }
if (cacheFolder.size() > LIMIT)
do {
cache.entries.firstOrNull { !channels.containsKey(it.key) }?.let {
File(it.value).delete()
cache.remove(it.key)
}
} while (cacheFolder.size() > LIMIT / 2)
}
fun free(images: List<String>) {
client.dispatcher().let { it.queuedCalls() + it.runningCalls() }
.filter { it.request().url().toString() in images }
.forEach { it.cancel() }
images.forEach { _channels.remove(it) }
}
@Synchronized
suspend fun clear() = coroutineScope {
client.dispatcher().queuedCalls().forEach { it.cancel() }
cacheFolder.listFiles()?.forEach { it.delete() }
cache.clear()
}
@OptIn(ExperimentalCoroutinesApi::class)
fun load(request: Request): File {
val key = request.url().toString()
val channel = if (_channels[key]?.isClosedForSend == false)
_channels[key]!!
else
Channel<Float>(1, BufferOverflow.DROP_OLDEST).also { _channels[key] = it }
return cache[key]?.let {
channel.close()
File(it)
} ?: File(cacheFolder, "${UUID.randomUUID()}.${key.takeLastWhile { it != '.' }}").also { file ->
client.newCall(request).enqueue(object: Callback {
override fun onFailure(call: Call, e: IOException) {
file.delete()
cache.remove(call.request().url().toString())
FirebaseCrashlytics.getInstance().recordException(e)
channel.close(e)
}
override fun onResponse(call: Call, response: Response) {
if (response.code() != 200) {
file.delete()
cache.remove(call.request().url().toString())
channel.close(IOException("HTTP Response code is not 200"))
response.close()
return
}
response.body()?.use { body ->
if (!file.exists())
file.createNewFile()
body.byteStream().copyTo(file.outputStream()) { bytes, _ ->
channel.sendBlocking(bytes / body.contentLength().toFloat() * 100)
}
}
channel.close()
}
})
}.also { cache[key] = it.canonicalPath }
}
}

View File

@@ -0,0 +1,170 @@
/*
* 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
import androidx.annotation.RequiresApi
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.KSerializer
import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.SetSerializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.serializer
import java.io.File
class SavedSet <T: Any> (private val file: File, any: T, private val set: MutableSet<T> = mutableSetOf()) : MutableSet<T> by set {
@Suppress("UNCHECKED_CAST")
@OptIn(ExperimentalSerializationApi::class)
val serializer: KSerializer<Set<T>> = SetSerializer(serializer(any::class.java) as KSerializer<T>)
init {
if (!file.exists()) {
file.parentFile?.mkdirs()
save()
}
load()
}
@Synchronized
fun load() {
set.clear()
kotlin.runCatching {
Json.decodeFromString(serializer, file.readText())
}.onSuccess {
set.addAll(it)
}
}
@Synchronized
fun save() {
if (!file.exists())
file.createNewFile()
file.writeText(Json.encodeToString(serializer, set))
}
@Synchronized
override fun add(element: T): Boolean {
set.remove(element)
return set.add(element).also {
save()
}
}
@Synchronized
override fun addAll(elements: Collection<T>): Boolean {
set.removeAll(elements)
return set.addAll(elements).also {
save()
}
}
@Synchronized
override fun remove(element: T): Boolean {
load()
return set.remove(element).also {
save()
}
}
@Synchronized
override fun clear() {
set.clear()
save()
}
}
class SavedMap <K: Any, V: Any> (private val file: File, anyKey: K, anyValue: V, private val map: MutableMap<K, V> = mutableMapOf()) : MutableMap<K, V> by map {
@Suppress("UNCHECKED_CAST")
@OptIn(ExperimentalSerializationApi::class)
val serializer: KSerializer<Map<K, V>> = MapSerializer(serializer(anyKey::class.java) as KSerializer<K>, serializer(anyValue::class.java) as KSerializer<V>)
init {
if (!file.exists()) {
file.parentFile?.mkdirs()
save()
}
load()
}
@Synchronized
fun load() {
map.clear()
kotlin.runCatching {
Json.decodeFromString(serializer, file.readText())
}.onSuccess {
map.putAll(it)
}
}
@Synchronized
fun save() {
if (!file.exists())
file.createNewFile()
file.writeText(Json.encodeToString(serializer, map))
}
@Synchronized
override fun put(key: K, value: V): V? {
map.remove(key)
return map.put(key, value).also {
save()
}
}
@Synchronized
override fun putAll(from: Map<out K, V>) {
for (key in from.keys) {
map.remove(key)
}
map.putAll(from)
save()
}
@Synchronized
override fun remove(key: K): V? {
return map.remove(key).also {
save()
}
}
@Synchronized
@RequiresApi(24)
override fun remove(key: K, value: V): Boolean {
return map.remove(key, value).also {
save()
}
}
@Synchronized
override fun clear() {
map.clear()
save()
}
}

View File

@@ -1,95 +0,0 @@
/*
* 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
import kotlinx.serialization.*
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import java.io.File
import java.util.*
class SavedSet <T: Any> (private val file: File, private val any: T, private val set: MutableSet<T> = mutableSetOf()) : MutableSet<T> by set {
@Suppress("UNCHECKED_CAST")
@OptIn(ExperimentalSerializationApi::class)
val serializer: KSerializer<List<T>>
get() = ListSerializer(serializer(any::class.java) as KSerializer<T>)
init {
if (!file.exists()) {
file.parentFile?.mkdirs()
save()
}
load()
}
@Synchronized
fun load() {
set.clear()
kotlin.runCatching {
Json.decodeFromString(serializer, file.readText())
}.onSuccess {
set.addAll(it)
}
}
@Synchronized
@OptIn(ExperimentalSerializationApi::class)
fun save() {
file.writeText(Json.encodeToString(serializer, set.toList()))
}
@Synchronized
override fun add(element: T): Boolean {
load()
set.remove(element)
return set.add(element).also {
save()
}
}
@Synchronized
override fun addAll(elements: Collection<T>): Boolean {
load()
set.removeAll(elements)
return set.addAll(elements).also {
save()
}
}
@Synchronized
override fun remove(element: T): Boolean {
load()
return set.remove(element).also {
save()
}
}
@Synchronized
override fun clear() {
set.clear()
save()
}
}

View File

@@ -1,117 +0,0 @@
/*
* 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 android.content.ContextWrapper
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import xyz.quaver.io.FileX
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 itemInfo: ItemInfo? = null,
var imageList: MutableList<String?>? = null
) {
fun copy(): Metadata = Metadata(itemInfo, imageList?.let { MutableList(it.size) { i -> it[i] } })
}
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, 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(source: String, itemID: String) {
val key = "$source/$itemID"
instances[key]?.cacheFolder?.deleteRecursively()
instances.remove("$source/$itemID")
}
}
val source = sources[source]!!
val downloadFolder: FileX?
get() = DownloadManager.getInstance(this).getDownloadFolder(source.name, itemID)
val cacheFolder: FileX
get() = FileX(this, cacheDir, "imageCache/$source/$itemID").also {
if (!it.exists())
it.mkdirs()
}
val metadata: Metadata = kotlin.runCatching {
Json.decodeFromString<Metadata>(findFile(".metadata")!!.readText())
}.getOrDefault(Metadata())
@Suppress("BlockingMethodInNonBlockingContext")
fun setMetadata(change: (Metadata) -> Unit) {
change.invoke(metadata)
val file = cacheFolder.getChild(".metadata")
kotlin.runCatching {
if (!file.exists()) {
file.createNewFile()
}
file.writeText(Json.encodeToString(metadata))
}
}
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

@@ -1,300 +0,0 @@
/*
* 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.annotation.SuppressLint
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.TaskStackBuilder
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.R
import xyz.quaver.pupil.client
import xyz.quaver.pupil.interceptors
import xyz.quaver.pupil.services.DownloadService
import xyz.quaver.pupil.sources.sources
import xyz.quaver.pupil.ui.ReaderActivity
import xyz.quaver.pupil.util.cleanCache
import xyz.quaver.pupil.util.normalizeID
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 Notification
private val notificationManager by lazy {
NotificationManagerCompat.from(context)
}
private val serviceNotification by lazy {
NotificationCompat.Builder(context, "downloader")
.setContentTitle(context.getString(R.string.downloader_running))
.setProgress(0, 0, false)
.setSmallIcon(R.drawable.ic_notification)
.setOngoing(true)
}
private val notification = ConcurrentHashMap<String, NotificationCompat.Builder?>()
private fun initNotification(source: String, itemID: String) {
val key = "$source-$itemID"
val intent = Intent(context, ReaderActivity::class.java)
.putExtra("source", source)
.putExtra("itemID", itemID)
val pendingIntent = TaskStackBuilder.create(context).run {
addNextIntentWithParentStack(intent)
getPendingIntent(itemID.hashCode(), PendingIntent.FLAG_UPDATE_CURRENT)
}
val action =
NotificationCompat.Action.Builder(0, context.getText(android.R.string.cancel),
PendingIntent.getService(
context,
R.id.notification_download_cancel_action.normalizeID(),
Intent(context, DownloadService::class.java)
.putExtra(DownloadService.KEY_COMMAND, DownloadService.COMMAND_CANCEL)
.putExtra(DownloadService.KEY_ID, itemID),
PendingIntent.FLAG_UPDATE_CURRENT),
).build()
notification[key] = NotificationCompat.Builder(context, "download").apply {
setContentTitle(context.getString(R.string.reader_loading))
setContentText(context.getString(R.string.reader_notification_text))
setSmallIcon(R.drawable.ic_notification)
setContentIntent(pendingIntent)
addAction(action)
setProgress(0, 0, true)
setOngoing(true)
}
notify(source, itemID)
}
@SuppressLint("RestrictedApi")
private fun notify(source: String, itemID: String) {
val key = "$source-$itemID"
val max = progress[key]?.size ?: 0
val progress = progress[key]?.count { it == Float.POSITIVE_INFINITY } ?: 0
val notification = notification[key] ?: return
if (isCompleted(source, itemID)) {
notification
.setContentText(context.getString(R.string.reader_notification_complete))
.setProgress(0, 0, false)
.setOngoing(false)
.mActions.clear()
notificationManager.cancel(key.hashCode())
} else
notification
.setProgress(max, progress, false)
.setContentText("$progress/$max")
}
//endregion
//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 isCompleted(source: String, itemID: String) = progress["$source-$itemID"]?.all { it == Float.POSITIVE_INFINITY } == true
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
initNotification(source, itemID)
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
}
if (cache.metadata.imageList == null)
cache.metadata.imageList = MutableList(it.size) { null }
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

@@ -18,50 +18,7 @@
package xyz.quaver.pupil.util package xyz.quaver.pupil.util
import android.content.Context
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import xyz.quaver.pupil.histories
import xyz.quaver.pupil.util.downloader.Cache
import xyz.quaver.pupil.util.downloader.DownloadManager
import java.io.File import java.io.File
val mutex = Mutex() fun File.size(): Long =
fun cleanCache(context: Context) = CoroutineScope(Dispatchers.IO).launch { this.walk().fold(0L) { size, file -> size + file.length() }
if (mutex.isLocked) return@launch
mutex.withLock {
val cacheFolder = File(context.cacheDir, "imageCache")
val downloadManager = DownloadManager.getInstance(context)
val limit = (Preferences.get<String>("cache_limit").toLongOrNull() ?: 0L)*1024*1024*1024
if (limit == 0L) return@withLock
val cacheSize = {
var size = 0L
cacheFolder.walk().forEach {
size += it.length()
}
size
}
if (cacheSize.invoke() > limit)
while (cacheSize.invoke() > limit/2) {
val caches = cacheFolder.list() ?: return@withLock
synchronized(histories) {
(histories.firstOrNull {
TODO()
} ?: return@withLock).let {
TODO()
}
}
}
}
}

View File

@@ -20,6 +20,7 @@ package xyz.quaver.pupil.util
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.view.MenuItem import android.view.MenuItem
import androidx.lifecycle.MutableLiveData
import kotlinx.serialization.json.* import kotlinx.serialization.json.*
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
@@ -27,6 +28,8 @@ 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.pupil.sources.ItemInfo import xyz.quaver.pupil.sources.ItemInfo
import java.io.InputStream
import java.io.OutputStream
import java.util.* import java.util.*
import kotlin.collections.ArrayList import kotlin.collections.ArrayList
@@ -84,47 +87,13 @@ val formatMap = mapOf<String, ItemInfo.() -> (String)>(
/** /**
* Formats download folder name with given Metadata * Formats download folder name with given Metadata
*/ */
fun ItemInfo.formatDownloadFolder(): String = fun ItemInfo.formatDownloadFolder(format: String = Preferences["download_folder_name", "[-id-] -title-"]): 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 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)
} }
}.replace(Regex("""[*\\|"?><:/]"""), "").ellipsize(127) }.replace(Regex("""[*\\|"?><:/]"""), "").ellipsize(127)
val GalleryInfo.requestBuilders: List<Request.Builder>
get() {
val galleryID = this.id ?: 0
val lowQuality = Preferences["low_quality", true]
return this.files.map {
Request.Builder()
.url(imageUrlFromImage(galleryID, it, !lowQuality))
.header("Referer", getReferer(galleryID))
}
/*
return when(code) {
Code.HITOMI -> {
this.galleryInfo.files.map {
Request.Builder()
.url(imageUrlFromImage(galleryID, it, !lowQuality))
.header("Referer", getReferer(galleryID))
}
}
Code.HIYOBI -> {
createImgList(galleryID, this, lowQuality).map {
Request.Builder()
.url(it.path)
}
}
}*/
}
fun String.ellipsize(n: Int): String = fun String.ellipsize(n: Int): String =
if (this.length > n) if (this.length > n)
this.slice(0 until n) + "" this.slice(0 until n) + ""
@@ -142,4 +111,21 @@ val JsonElement.content
fun List<MenuItem>.findMenu(itemID: Int): MenuItem { fun List<MenuItem>.findMenu(itemID: Int): MenuItem {
return first { it.itemId == itemID } return first { it.itemId == itemID }
}
fun <E> MutableLiveData<MutableList<E>>.notify() {
this.value = this.value
}
fun InputStream.copyTo(out: OutputStream, onCopy: (totalBytesCopied: Long, bytesJustCopied: Int) -> Any): Long {
var bytesCopied: Long = 0
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var bytes = read(buffer)
while (bytes >= 0) {
out.write(buffer, 0, bytes)
bytesCopied += bytes
onCopy(bytesCopied, bytes)
bytes = read(buffer)
}
return bytesCopied
} }

View File

@@ -45,21 +45,10 @@ import okhttp3.Callback
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import ru.noties.markwon.Markwon import ru.noties.markwon.Markwon
import xyz.quaver.hitomi.GalleryBlock
import xyz.quaver.hitomi.getGalleryBlock
import xyz.quaver.hitomi.getReader
import xyz.quaver.io.FileX
import xyz.quaver.io.util.getChild
import xyz.quaver.io.util.readText
import xyz.quaver.io.util.writeBytes
import xyz.quaver.io.util.writeText
import xyz.quaver.pupil.BuildConfig import xyz.quaver.pupil.BuildConfig
import xyz.quaver.pupil.R import xyz.quaver.pupil.R
import xyz.quaver.pupil.client import xyz.quaver.pupil.client
import xyz.quaver.pupil.favorites import xyz.quaver.pupil.favorites
import xyz.quaver.pupil.services.DownloadService
import xyz.quaver.pupil.util.downloader.Cache
import xyz.quaver.pupil.util.downloader.Metadata
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.net.URL import java.net.URL

View File

@@ -1,53 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Pupil, Hitomi.la viewer for Android
~ Copyright (C) 2019 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/>.
-->
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:padding="16dp">
<TextView
style="?android:textAppearanceLarge"
android:id="@+id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/reader_go_to_page"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"/>
<NumberPicker
android:id="@+id/number_picker"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@id/title"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"/>
<Button
android:id="@+id/ok_button"
style="?borderlessButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@android:string/ok"
app:layout_constraintTop_toBottomOf="@id/number_picker"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -20,6 +20,7 @@
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.coordinatorlayout.widget.CoordinatorLayout 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"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/root"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="@android:color/darker_gray" android:background="@android:color/darker_gray"
@@ -42,7 +43,8 @@
android:id="@+id/recyclerview" android:id="@+id/recyclerview"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"/> app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/reader_item"/>
</com.qtalk.recyclerviewfastscroller.RecyclerViewFastScroller> </com.qtalk.recyclerviewfastscroller.RecyclerViewFastScroller>
@@ -59,7 +61,7 @@
style="@style/Widget.AppCompat.ProgressBar.Horizontal" style="@style/Widget.AppCompat.ProgressBar.Horizontal"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="4dp"/> android:layout_height="4dp"/>
<com.github.clans.fab.FloatingActionMenu <com.github.clans.fab.FloatingActionMenu
android:id="@+id/fab" android:id="@+id/fab"
android:layout_width="wrap_content" android:layout_width="wrap_content"
@@ -84,14 +86,6 @@
app:fab_label="@string/reader_fab_retry" app:fab_label="@string/reader_fab_retry"
app:fab_size="mini"/> app:fab_size="mini"/>
<com.github.clans.fab.FloatingActionButton
android:id="@+id/auto_fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:srcCompat="@drawable/eye_white"
app:fab_label="@string/reader_fab_auto"
app:fab_size="mini"/>
<com.github.clans.fab.FloatingActionButton <com.github.clans.fab.FloatingActionButton
android:id="@+id/fullscreen_fab" android:id="@+id/fullscreen_fab"
android:layout_width="wrap_content" android:layout_width="wrap_content"

View File

@@ -23,7 +23,7 @@
android:layout_height="match_parent" 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">
<androidx.constraintlayout.widget.Guideline <androidx.constraintlayout.widget.Guideline
android:id="@+id/guideline_center_vertical" android:id="@+id/guideline_center_vertical"
android:layout_width="wrap_content" android:layout_width="wrap_content"
@@ -52,7 +52,7 @@
app:layout_constraintLeft_toLeftOf="@id/reader_item_progressbar" app:layout_constraintLeft_toLeftOf="@id/reader_item_progressbar"
app:layout_constraintRight_toRightOf="@id/reader_item_progressbar" app:layout_constraintRight_toRightOf="@id/reader_item_progressbar"
style="@style/TextAppearance.AppCompat.Caption"/> style="@style/TextAppearance.AppCompat.Caption"/>
<androidx.constraintlayout.widget.Group <androidx.constraintlayout.widget.Group
android:id="@+id/progress_group" android:id="@+id/progress_group"
android:layout_width="wrap_content" android:layout_width="wrap_content"

View File

@@ -25,7 +25,7 @@
android:icon="@drawable/avd_star" android:icon="@drawable/avd_star"
app:showAsAction="always"/> app:showAsAction="always"/>
<item android:id="@+id/reader_type" <item android:id="@+id/source"
android:title="" android:title=""
app:showAsAction="ifRoom"/> app:showAsAction="ifRoom"/>

View File

@@ -43,24 +43,10 @@
app:key="download_folder" app:key="download_folder"
app:title="@string/settings_download_folder"/> app:title="@string/settings_download_folder"/>
<ListPreference
app:key="cache_limit"
app:title="@string/settings_cache_limit"
app:entries="@array/cache_size_text"
app:entryValues="@array/cache_size"
app:defaultValue="8"
app:useSimpleSummaryProvider="true"/>
<SwitchPreferenceCompat <SwitchPreferenceCompat
app:key="nomedia" app:key="nomedia"
app:title="@string/settings_nomedia_title"/> app:title="@string/settings_nomedia_title"/>
<SwitchPreferenceCompat
app:key="low_quality"
app:title="@string/settings_low_quality"
app:summary="@string/settings_low_quality_summary"
app:defaultValue="true"/>
</PreferenceCategory> </PreferenceCategory>
<PreferenceCategory <PreferenceCategory
@@ -80,11 +66,6 @@
app:title="@string/settings_tag_translation" app:title="@string/settings_tag_translation"
app:useSimpleSummaryProvider="true"/> app:useSimpleSummaryProvider="true"/>
<Preference
app:key="mirrors"
app:title="@string/settings_mirror_title"
app:summary="@string/settings_mirror_summary"/>
<Preference <Preference
app:key="proxy" app:key="proxy"
app:title="@string/settings_proxy_title"/> app:title="@string/settings_proxy_title"/>

View File

@@ -21,7 +21,7 @@ buildscript {
allprojects { allprojects {
repositories { repositories {
maven { url "http://dl.bintray.com/piasy/maven" } maven { url "https://dl.bintray.com/piasy/maven" }
google() google()
jcenter() jcenter()
maven { url "https://jitpack.io" } maven { url "https://jitpack.io" }