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

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

View File

@@ -26,7 +26,6 @@ import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.util.Log
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.content.ContextCompat
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.crashlytics.FirebaseCrashlytics
import com.google.firebase.ktx.Firebase
import okhttp3.Dispatcher
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Response
import okhttp3.*
import org.kodein.di.*
import org.kodein.di.android.x.androidXModule
import xyz.quaver.io.FileX
import xyz.quaver.pupil.sources.initSources
import xyz.quaver.pupil.sources.sourceModule
import xyz.quaver.pupil.types.Tag
import xyz.quaver.pupil.util.*
import xyz.quaver.setClient
import java.io.File
import java.util.*
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import kotlin.reflect.KClass
typealias PupilInterceptor = (Interceptor.Chain) -> Response
lateinit var histories: SavedSet<String>
private set
@@ -65,8 +59,6 @@ lateinit var favoriteTags: SavedSet<Tag>
lateinit var searchHistory: SavedSet<String>
private set
val interceptors = mutableMapOf<KClass<out Any>, PupilInterceptor>()
lateinit var clientBuilder: OkHttpClient.Builder
var clientHolder: OkHttpClient? = null
@@ -76,7 +68,16 @@ val client: OkHttpClient
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
@@ -90,24 +91,15 @@ class Pupil : Application() {
else userID
}
initSources(this)
firebaseAnalytics = Firebase.analytics
FirebaseCrashlytics.getInstance().setUserId(userID)
initSources(this)
val proxyInfo = getProxyInfo()
clientBuilder = OkHttpClient.Builder()
.connectTimeout(0, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.SECONDS)
.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 {
Preferences.get<String>("download_folder").also {

View File

@@ -19,33 +19,66 @@
package xyz.quaver.pupil.adapters
import android.content.Context
import android.graphics.drawable.Animatable
import android.net.Uri
import android.view.LayoutInflater
import android.view.View
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.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import com.facebook.imagepipeline.image.ImageInfo
import com.github.piasy.biv.loader.ImageLoader
import com.github.piasy.biv.view.BigImageView
import com.github.piasy.biv.view.ImageShownCallback
import com.github.piasy.biv.view.ImageViewFactory
import xyz.quaver.pupil.R
import xyz.quaver.pupil.databinding.ReaderItemBinding
import xyz.quaver.pupil.util.downloader.Cache
import xyz.quaver.pupil.util.downloader.Downloader
import java.io.File
import java.lang.Exception
import kotlin.math.roundToInt
class ReaderAdapter(
private val context: Context,
private val source: String,
private val itemID: String
) : RecyclerView.Adapter<ReaderAdapter.ViewHolder>() {
data class ReaderItem(
val progress: Float,
val image: File?
)
class ReaderAdapter : ListAdapter<ReaderItem, ReaderAdapter.ViewHolder>(ReaderItemDiffCallback()) {
var onItemClickListener : (() -> (Unit))? = null
var fullscreen = false
inner class ViewHolder(private val binding: ReaderItemBinding) : RecyclerView.ViewHolder(binding.root) {
init {
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))
setOnClickListener {
onItemClickListener?.invoke()
@@ -60,42 +93,35 @@ class ReaderAdapter(
}
fun bind(position: Int) {
recycle()
binding.root.layoutParams.height = MATCH_PARENT
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.image.showImage(image)
else {
val progress = Downloader.getInstance(context).getProgress(source, itemID)?.get(position) ?: 0F
binding.progressGroup.visibility = View.VISIBLE
if (image != null) {
binding.root.background = null
binding.image.showImage(Uri.fromFile(image))
} else {
binding.root.setBackgroundResource(R.drawable.reader_item_boundary)
if (progress == Float.NEGATIVE_INFINITY)
with (binding.image) {
showImage(Uri.EMPTY)
setOnClickListener {
if (Downloader.getInstance(context).getProgress(source, itemID)?.get(position) == Float.NEGATIVE_INFINITY)
Downloader.getInstance(context).retry(source, itemID)
}
}
else {
binding.image.showImage(Uri.EMPTY)
else
binding.readerItemProgressbar.progress = progress.roundToInt()
CoroutineScope(Dispatchers.Main).launch {
delay(1000)
notifyItemChanged(position)
}
}
}
}
fun clear() {
binding.image.mainView.let {
when (it) {
is SubsamplingScaleImageView ->
it.recycle()
is SimpleDraweeView ->
it.controller = null
fun recycle() {
binding.image.mainView.run {
when (this) {
is SubsamplingScaleImageView -> recycle()
is SimpleDraweeView -> recycle()
is ImageView -> setImageBitmap(null)
}
}
}
@@ -109,10 +135,102 @@ class ReaderAdapter(
holder.bind(position)
}
override fun getItemCount() = Downloader.getInstance(context).getProgress(source, itemID)?.size ?: 0
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.sources.ItemInfo
import xyz.quaver.pupil.types.Tag
import xyz.quaver.pupil.ui.view.ProgressCardView
import xyz.quaver.pupil.util.downloader.Cache
import xyz.quaver.pupil.util.downloader.DownloadManager
import xyz.quaver.pupil.util.downloader.Downloader
import kotlin.time.ExperimentalTime
class SearchResultsAdapter(private val results: List<ItemInfo>) : RecyclerSwipeAdapter<SearchResultsAdapter.ViewHolder>(), SwipeAdapterInterface {
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
// TODO: migrate to viewBinding
@@ -78,11 +74,7 @@ class SearchResultsAdapter(private val results: List<ItemInfo>) : RecyclerSwipeA
override fun onStartOpen(layout: SwipeLayout?) {
mItemManger.closeAllExcept(layout)
binding.root.binding.download.text =
if (Downloader.getInstance(itemView.context).isDownloading(source, itemID))
itemView.context.getString(android.R.string.cancel)
else
itemView.context.getString(R.string.main_download)
binding.root.binding.download.text = itemView.context.getString(R.string.main_download)
}
override fun onOpen(layout: SwipeLayout?) {}
@@ -117,8 +109,7 @@ class SearchResultsAdapter(private val results: List<ItemInfo>) : RecyclerSwipeA
}
private fun updateProgress() {
val cache = Cache.getInstance(itemView.context, source, itemID)
/* TODO
binding.root.max = cache.metadata.imageList?.size ?: 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
} else
ProgressCardView.Type.LOADING
*/
}
@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.encoding.Decoder
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.suggestions.model.SearchSuggestion
import xyz.quaver.pupil.R
@@ -110,6 +114,7 @@ enum class DefaultSortMode {
@Parcelize
class DefaultSearchSuggestion(override val body: String) : SearchSuggestion
typealias AnySource = Source<*, SearchSuggestion>
abstract class Source<Query_SortMode: Enum<Query_SortMode>, Suggestion: SearchSuggestion> {
abstract val name: String
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 suggestion(query: String) : List<Suggestion>
abstract suspend fun images(id: String) : List<String>
abstract suspend fun info(id: String) : ItemInfo
abstract suspend fun images(itemID: String) : List<String>
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()
}
@@ -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?>()
@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")
fun initSources(context: Context) {
// Add Default Sources
@@ -139,7 +156,7 @@ fun initSources(context: Context) {
Hitomi(),
Hiyobi()
).forEach {
sources[it.name] = it as Source<*, SearchSuggestion>
sources[it.name] = it as AnySource
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> {
val galleryID = id.toInt()
override suspend fun images(itemID: String): List<String> {
val galleryID = itemID.toInt()
val reader = getGalleryInfo(galleryID)
@@ -108,32 +108,36 @@ class Hitomi : Source<Hitomi.SortMode, Hitomi.TagSuggestion>() {
}
}
override suspend fun info(id: String): ItemInfo = coroutineScope {
getGallery(id.toInt()).let {
ItemInfo(
name,
id,
it.title,
it.cover,
it.artists.joinToString { it.wordCapitalize() },
mapOf(
ExtraType.TYPE to async { it.type.wordCapitalize() },
ExtraType.GROUP to async { it.groups.joinToString { it.wordCapitalize() } },
ExtraType.LANGUAGE to async { languageMap[it.language] ?: it.language },
ExtraType.SERIES to async { it.series.joinToString { it.wordCapitalize() } },
ExtraType.CHARACTER to async { it.characters.joinToString { it.wordCapitalize() } },
ExtraType.TAGS to async { it.tags.joinToString() },
ExtraType.PREVIEW to async { it.thumbnails.joinToString() },
ExtraType.RELATED_ITEM to async { it.related.joinToString() },
ExtraType.PAGECOUNT to async { it.thumbnails.size.toString() },
override suspend fun info(itemID: String): ItemInfo = coroutineScope {
kotlin.runCatching {
getGallery(itemID.toInt()).let {
ItemInfo(
name,
itemID,
it.title,
it.cover,
it.artists.joinToString { it.wordCapitalize() },
mapOf(
ExtraType.TYPE to async { it.type.wordCapitalize() },
ExtraType.GROUP to async { it.groups.joinToString { it.wordCapitalize() } },
ExtraType.LANGUAGE to async { languageMap[it.language] ?: it.language },
ExtraType.SERIES to async { it.series.joinToString { it.wordCapitalize() } },
ExtraType.CHARACTER to async { it.characters.joinToString { it.wordCapitalize() } },
ExtraType.TAGS to async { it.tags.joinToString() },
ExtraType.PREVIEW to async { it.thumbnails.joinToString() },
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(
"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) }
}
override suspend fun images(id: String): List<String> {
return createImgList(id, getGalleryInfo(id), true).map {
override suspend fun images(itemID: String): List<String> {
return createImgList(itemID, getGalleryInfo(itemID), false).map {
it.path
}
}
override suspend fun info(id: String): ItemInfo {
return transform(name, getGalleryBlock(id))
override suspend fun info(itemID: String): ItemInfo {
return transform(name, getGalleryBlock(itemID))
}
override fun onSuggestionBind(binding: SearchSuggestionItemBinding, item: DefaultSearchSuggestion) {
@@ -105,7 +105,7 @@ class Hiyobi : Source<DefaultSortMode, DefaultSearchSuggestion>() {
}
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 {
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() ?: "[]")
@@ -114,7 +114,7 @@ class Hiyobi : Source<DefaultSortMode, DefaultSearchSuggestion>() {
private var _allTags: Deferred<List<String>>? = null
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
} else _allTags!!

View File

@@ -50,7 +50,6 @@ import xyz.quaver.floatingsearchview.util.view.SearchInputView
import xyz.quaver.pupil.*
import xyz.quaver.pupil.adapters.SearchResultsAdapter
import xyz.quaver.pupil.databinding.MainActivityBinding
import xyz.quaver.pupil.services.DownloadService
import xyz.quaver.pupil.sources.ItemInfo
import xyz.quaver.pupil.sources.Source
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.SwipePageTurnView
import xyz.quaver.pupil.util.*
import xyz.quaver.pupil.util.downloader.Cache
import xyz.quaver.pupil.util.downloader.DownloadManager
import xyz.quaver.pupil.util.downloader.Downloader
import java.util.regex.Pattern
import kotlin.math.*
import kotlin.random.Random
@@ -223,7 +219,7 @@ class MainActivity :
with (binding.contents.cancelFab) {
setImageResource(R.drawable.cancel)
setOnClickListener {
Downloader.getInstance(context).cancel()
}
}
@@ -354,22 +350,12 @@ class MainActivity :
query()
}
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()
}
onDeleteClickedHandler = { source, itemID ->
Downloader.getInstance(context).cancel(source, itemID)
Cache.delete(source, itemID)
histories.remove(itemID)
closeAllItems()
}

View File

@@ -22,24 +22,30 @@ import android.content.Intent
import android.graphics.drawable.Animatable
import android.os.Bundle
import android.view.*
import androidx.appcompat.app.AlertDialog
import androidx.activity.viewModels
import androidx.core.content.ContextCompat
import androidx.core.view.forEach
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.PagerSnapHelper
import androidx.recyclerview.widget.RecyclerView
import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
import com.google.firebase.crashlytics.FirebaseCrashlytics
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.adapters.ReaderAdapter
import xyz.quaver.pupil.databinding.NumberpickerDialogBinding
import xyz.quaver.pupil.databinding.ReaderActivityBinding
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.downloader.Downloader
class ReaderActivity : BaseActivity() {
class ReaderActivity : BaseActivity(), DIAware {
override val di by di()
private var source = ""
private var itemID = ""
@@ -50,14 +56,13 @@ class ReaderActivity : BaseActivity() {
private var isFullscreen = false
set(value) {
field = value
//(binding.recyclerview.adapter as ReaderAdapter).isFullScreen = value
}
private val snapHelper = PagerSnapHelper()
private var menu: Menu? = null
private lateinit var binding: ReaderActivityBinding
private val model: ReaderViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -68,36 +73,34 @@ class ReaderActivity : BaseActivity() {
supportActionBar?.setDisplayHomeAsUpEnabled(false)
handleIntent(intent)
FirebaseCrashlytics.getInstance().setCustomKey("GalleryID", itemID)
if (itemID.isEmpty()) {
onBackPressed()
return
}
with (Downloader.getInstance(this)) {
onImageListLoadedCallback = {
runOnUiThread {
binding.recyclerview.adapter?.notifyDataSetChanged()
}
}
download(source, itemID)
}
FirebaseCrashlytics.getInstance().setCustomKey("GalleryID", itemID)
binding.recyclerview.adapter = ReaderAdapter(this, source, itemID).apply {
onItemClickListener = {
if (isScroll) {
isScroll = false
isFullscreen = true
model.readerItems.observe(this) {
(binding.recyclerview.adapter as ReaderAdapter).submitList(it.toMutableList())
scrollMode(false)
fullscreen(true)
} else {
(binding.recyclerview.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(currentPage, 0) //Moves to next page because currentPage is 1-based indexing
}
binding.downloadProgressbar.apply {
max = it.size
progress = it.count { it.image != null }
visibility =
if (progress == max)
View.GONE
else
View.VISIBLE
}
}
model.title.observe(this) {
title = it
}
model.load(source, itemID)
initView()
}
@@ -129,11 +132,16 @@ class ReaderActivity : BaseActivity() {
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.reader, menu)
with (menu?.findItem(R.id.reader_menu_favorite)) {
this ?: return@with
if (favorites.contains(itemID))
(icon as Animatable).start()
menu?.forEach {
when (it.itemId) {
R.id.reader_menu_favorite -> {
if (favorites.contains(itemID))
(it.icon as Animatable).start()
}
R.id.source -> {
it.setIcon(direct.instance<AnySource>(tag = source).iconResID)
}
}
}
this.menu = menu
@@ -142,25 +150,6 @@ class ReaderActivity : BaseActivity() {
override fun onOptionsItemSelected(item: MenuItem): Boolean {
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 -> {
val id = itemID
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 {
//currentPage is 1-based
return when(keyCode) {
KeyEvent.KEYCODE_VOLUME_UP -> {
(binding.recyclerview.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(currentPage-2, 0)
(binding.recyclerview.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(currentPage-1, 0)
true
}
KeyEvent.KEYCODE_VOLUME_DOWN -> {
(binding.recyclerview.layoutManager as LinearLayoutManager?)?.scrollToPositionWithOffset(currentPage, 0)
(binding.recyclerview.layoutManager as LinearLayoutManager?)?.scrollToPositionWithOffset(currentPage+1, 0)
true
}
@@ -212,6 +200,20 @@ class ReaderActivity : BaseActivity() {
private fun initView() {
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() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
@@ -225,16 +227,19 @@ class ReaderActivity : BaseActivity() {
if (layoutManager.findFirstVisibleItemPosition() == -1)
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) {
setImageResource(R.drawable.refresh)
setOnClickListener {
DownloadService.download(context, itemID)
}
}
@@ -250,6 +255,8 @@ class ReaderActivity : BaseActivity() {
}
private fun fullscreen(isFullscreen: Boolean) {
(binding.recyclerview.adapter as ReaderAdapter).fullscreen = isFullscreen
with (window.attributes) {
if (isFullscreen) {
flags = flags or WindowManager.LayoutParams.FLAG_FULLSCREEN
@@ -282,20 +289,17 @@ class ReaderActivity : BaseActivity() {
private fun scrollMode(isScroll: Boolean) {
if (isScroll) {
snapHelper.attachToRecyclerView(null)
binding.recyclerview.layoutManager = object: LinearLayoutManager(this) {
override fun calculateExtraLayoutSpace(state: RecyclerView.State, extraLayoutSpace: IntArray) {
extraLayoutSpace.fill(600)
}
}
binding.recyclerview.layoutManager = LinearLayoutManager(this)
} else {
snapHelper.attachToRecyclerView(binding.recyclerview)
binding.recyclerview.layoutManager = object: LinearLayoutManager(this, HORIZONTAL, Preferences["rtl", false]) {
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 net.rdrei.android.dirchooser.DirectoryChooserActivity
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.util.toFile
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.util.Preferences
import xyz.quaver.pupil.util.byteToString
import xyz.quaver.pupil.util.downloader.DownloadManager
import xyz.quaver.pupil.util.DownloadManager
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 val binding get() = _binding!!
@@ -69,7 +76,7 @@ class DownloadLocationDialogFragment : DialogFragment() {
Snackbar.LENGTH_LONG
).show()
val downloadFolder = DownloadManager.getInstance(context).downloadFolder.canonicalPath
val downloadFolder = downloadManager.downloadFolder.canonicalPath
val key = entries.keys.firstOrNull { it?.canonicalPath == downloadFolder }
entries[key]!!.button.isChecked = true
if (key == null) entries[key]!!.locationAvailable.text = downloadFolder
@@ -92,7 +99,7 @@ class DownloadLocationDialogFragment : DialogFragment() {
Snackbar.LENGTH_LONG
).show()
val downloadFolder = DownloadManager.getInstance(context).downloadFolder.canonicalPath
val downloadFolder = downloadManager.downloadFolder.canonicalPath
val key = entries.keys.firstOrNull { it?.canonicalPath == downloadFolder }
entries[key]!!.button.isChecked = true
if (key == null) entries[key]!!.locationAvailable.text = downloadFolder
@@ -165,7 +172,7 @@ class DownloadLocationDialogFragment : DialogFragment() {
entries[null] = this
}
val downloadFolder = DownloadManager.getInstance(requireContext()).downloadFolder.canonicalPath
val downloadFolder = downloadManager.downloadFolder.canonicalPath
val key = entries.keys.firstOrNull { it?.canonicalPath == downloadFolder }
entries[key]!!.button.isChecked = true
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.databinding.*
import xyz.quaver.pupil.favoriteTags
import xyz.quaver.pupil.sources.Hitomi
import xyz.quaver.pupil.sources.ItemInfo
import xyz.quaver.pupil.types.Tag
import xyz.quaver.pupil.ui.ReaderActivity
import xyz.quaver.pupil.ui.view.TagChip
import xyz.quaver.pupil.util.ItemClickSupport
import xyz.quaver.pupil.util.downloader.Cache
import xyz.quaver.pupil.util.wordCapitalize
import java.util.*
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.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.*
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.util.deleteRecursively
import xyz.quaver.pupil.R
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.downloader.Cache
import xyz.quaver.pupil.util.downloader.DownloadManager
import java.io.File
import xyz.quaver.pupil.util.size
class ManageStorageFragment : PreferenceFragmentCompat(), Preference.OnPreferenceClickListener {
class ManageStorageFragment : PreferenceFragmentCompat(), DIAware, Preference.OnPreferenceClickListener {
override val di by di()
private var job: Job? = null
private val downloadManager: DownloadManager by instance()
private val cache: ImageCache by instance()
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.manage_storage_preferences, rootKey)
@@ -53,27 +57,19 @@ class ManageStorageFragment : PreferenceFragmentCompat(), Preference.OnPreferenc
when (key) {
"delete_cache" -> {
val dir = File(context.cacheDir, "imageCache")
val cache: ImageCache by instance()
AlertDialog.Builder(context).apply {
setTitle(R.string.warning)
setMessage(R.string.settings_clear_cache_alert_message)
setPositiveButton(android.R.string.ok) { _, _ ->
if (dir.exists())
dir.deleteRecursively()
summary = context.getString(R.string.settings_storage_usage_loading)
Cache.instances.clear()
summary = context.getString(R.string.settings_storage_usage, byteToString(0))
CoroutineScope(Dispatchers.IO).launch {
var size = 0L
cache.clear()
dir.walk().forEach {
size += it.length()
launch(Dispatchers.Main) {
summary = context.getString(R.string.settings_storage_usage, byteToString(size))
}
MainScope().launch {
summary = context.getString(R.string.settings_storage_usage, byteToString(cache.cacheFolder.size()))
}
}
}
@@ -81,7 +77,7 @@ class ManageStorageFragment : PreferenceFragmentCompat(), Preference.OnPreferenc
}.show()
}
"delete_downloads" -> {
val dir = DownloadManager.getInstance(context).downloadFolder
val dir = downloadManager.downloadFolder
AlertDialog.Builder(context).apply {
setTitle(R.string.warning)
@@ -143,21 +139,7 @@ class ManageStorageFragment : PreferenceFragmentCompat(), Preference.OnPreferenc
with (findPreference<Preference>("delete_cache")) {
this ?: return@with
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))
}
}
}
summary = context.getString(R.string.settings_storage_usage, byteToString(cache.cacheFolder.size()))
onPreferenceClickListener = this@ManageStorageFragment
}
@@ -165,7 +147,7 @@ class ManageStorageFragment : PreferenceFragmentCompat(), Preference.OnPreferenc
with (findPreference<Preference>("delete_downloads")) {
this ?: return@with
val dir = DownloadManager.getInstance(context).downloadFolder
val dir = downloadManager.downloadFolder
summary = context.getString(R.string.settings_storage_usage, byteToString(0))
job?.cancel()

View File

@@ -29,6 +29,9 @@ import com.google.android.gms.oss.licenses.OssLicensesMenuActivity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
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.util.getChild
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.dialog.*
import xyz.quaver.pupil.util.*
import xyz.quaver.pupil.util.downloader.DownloadManager
import xyz.quaver.pupil.util.DownloadManager
import java.util.*
class SettingsFragment :
PreferenceFragmentCompat(),
Preference.OnPreferenceClickListener,
Preference.OnPreferenceChangeListener,
SharedPreferences.OnSharedPreferenceChangeListener {
SharedPreferences.OnSharedPreferenceChangeListener,
DIAware {
override val di by di()
private val downloadManager: DownloadManager by instance()
private val lockLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK) {
@@ -98,10 +106,6 @@ class SettingsFragment :
}
lockLauncher.launch(intent)
}
"mirrors" -> {
MirrorDialog(requireContext())
.show()
}
"proxy" -> {
ProxyDialog(requireContext())
.show()
@@ -131,7 +135,7 @@ class SettingsFragment :
val create = (newValue as? Boolean) ?: return false
return kotlin.runCatching {
val nomedia = DownloadManager.getInstance(context).downloadFolder.getChild(".nomedia")
val nomedia = downloadManager.downloadFolder.getChild(".nomedia")
if (create)
nomedia.createNewFile()
@@ -220,7 +224,7 @@ class SettingsFragment :
}
"nomedia" -> {
(this as SwitchPreferenceCompat).isChecked = kotlin.runCatching {
DownloadManager.getInstance(context).downloadFolder.getChild(".nomedia").exists()
downloadManager.downloadFolder.getChild(".nomedia").exists()
}.getOrDefault(false)
onPreferenceChangeListener = this@SettingsFragment
@@ -268,9 +272,6 @@ class SettingsFragment :
onPreferenceChangeListener = this@SettingsFragment
}
"mirrors" -> {
onPreferenceClickListener = this@SettingsFragment
}
"proxy" -> {
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
* Copyright (C) 2020 tom5079
* 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
@@ -16,35 +16,29 @@
* 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.ContextWrapper
import android.net.Uri
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
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.util.*
import xyz.quaver.pupil.sources.sources
import xyz.quaver.pupil.util.Preferences
import xyz.quaver.pupil.util.formatDownloadFolder
import xyz.quaver.pupil.sources.AnySource
class DownloadManager private constructor(context: Context) : ContextWrapper(context) {
class DownloadManager constructor(context: Context) : ContextWrapper(context), DIAware {
companion object {
@Volatile private var instance: DownloadManager? = null
override val di by di(context)
fun getInstance(context: Context) =
instance ?: synchronized(this) {
instance ?: DownloadManager(context).also { instance = it }
}
}
val defaultDownloadFolder = FileX(this, getExternalFilesDir(null)!!)
private val defaultDownloadFolder = FileX(this, getExternalFilesDir(null)!!)
val downloadFolder: FileX
get() = {
@@ -58,7 +52,7 @@ class DownloadManager private constructor(context: Context) : ContextWrapper(con
private var prevDownloadFolder: FileX? = null
private var downloadFolderMapInstance: MutableMap<String, String>? = null
val downloadFolderMap: MutableMap<String, String>
private val downloadFolderMap: MutableMap<String, String>
@Synchronized
get() {
if (prevDownloadFolder != downloadFolder) {
@@ -88,8 +82,12 @@ class DownloadManager private constructor(context: Context) : ContextWrapper(con
downloadFolderMap["$source-$itemID"]?.let { downloadFolder.getChild(it) }
@Synchronized
fun addDownloadFolder(source: String, itemID: String) = CoroutineScope(Dispatchers.IO).launch {
val name = "A" // TODO
fun download(source: String, itemID: String) = CoroutineScope(Dispatchers.IO).launch {
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")
@@ -105,7 +103,7 @@ class DownloadManager private constructor(context: Context) : ContextWrapper(con
}
@Synchronized
fun deleteDownloadFolder(source: String, itemID: String) {
fun delete(source: String, itemID: String) {
downloadFolderMap["$source/$itemID"]?.let {
kotlin.runCatching {
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
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
val mutex = Mutex()
fun cleanCache(context: Context) = CoroutineScope(Dispatchers.IO).launch {
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()
}
}
}
}
}
fun File.size(): Long =
this.walk().fold(0L) { size, file -> size + file.length() }

View File

@@ -20,6 +20,7 @@ package xyz.quaver.pupil.util
import android.annotation.SuppressLint
import android.view.MenuItem
import androidx.lifecycle.MutableLiveData
import kotlinx.serialization.json.*
import okhttp3.OkHttpClient
import okhttp3.Request
@@ -27,6 +28,8 @@ import xyz.quaver.hitomi.GalleryInfo
import xyz.quaver.hitomi.getReferer
import xyz.quaver.hitomi.imageUrlFromImage
import xyz.quaver.pupil.sources.ItemInfo
import java.io.InputStream
import java.io.OutputStream
import java.util.*
import kotlin.collections.ArrayList
@@ -84,47 +87,13 @@ val formatMap = mapOf<String, ItemInfo.() -> (String)>(
/**
* Formats download folder name with given Metadata
*/
fun ItemInfo.formatDownloadFolder(): String =
Preferences["download_folder_name", "[-id-] -title-"].let {
formatMap.entries.fold(it) { str, (k, v) ->
str.replace(k, v.invoke(this), true)
}
}.replace(Regex("""[*\\|"?><:/]"""), "").ellipsize(127)
fun ItemInfo.formatDownloadFolderTest(format: String): String =
fun ItemInfo.formatDownloadFolder(format: String = Preferences["download_folder_name", "[-id-] -title-"]): String =
format.let {
formatMap.entries.fold(it) { str, (k, v) ->
str.replace(k, v.invoke(this), true)
}
}.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 =
if (this.length > n)
this.slice(0 until n) + ""
@@ -142,4 +111,21 @@ val JsonElement.content
fun List<MenuItem>.findMenu(itemID: Int): MenuItem {
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.Response
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.R
import xyz.quaver.pupil.client
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.IOException
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"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/darker_gray"
@@ -42,7 +43,8 @@
android:id="@+id/recyclerview"
android:layout_width="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>
@@ -59,7 +61,7 @@
style="@style/Widget.AppCompat.ProgressBar.Horizontal"
android:layout_width="match_parent"
android:layout_height="4dp"/>
<com.github.clans.fab.FloatingActionMenu
android:id="@+id/fab"
android:layout_width="wrap_content"
@@ -84,14 +86,6 @@
app:fab_label="@string/reader_fab_retry"
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
android:id="@+id/fullscreen_fab"
android:layout_width="wrap_content"

View File

@@ -23,7 +23,7 @@
android:layout_height="match_parent"
android:layout_marginBottom="8dp"
android:background="@drawable/reader_item_boundary">
<androidx.constraintlayout.widget.Guideline
android:id="@+id/guideline_center_vertical"
android:layout_width="wrap_content"
@@ -52,7 +52,7 @@
app:layout_constraintLeft_toLeftOf="@id/reader_item_progressbar"
app:layout_constraintRight_toRightOf="@id/reader_item_progressbar"
style="@style/TextAppearance.AppCompat.Caption"/>
<androidx.constraintlayout.widget.Group
android:id="@+id/progress_group"
android:layout_width="wrap_content"

View File

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

View File

@@ -43,24 +43,10 @@
app:key="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
app:key="nomedia"
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
@@ -80,11 +66,6 @@
app:title="@string/settings_tag_translation"
app:useSimpleSummaryProvider="true"/>
<Preference
app:key="mirrors"
app:title="@string/settings_mirror_title"
app:summary="@string/settings_mirror_summary"/>
<Preference
app:key="proxy"
app:title="@string/settings_proxy_title"/>