diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml index bfb025b5..45908c99 100644 --- a/.idea/jarRepositories.xml +++ b/.idea/jarRepositories.xml @@ -54,12 +54,12 @@ + + \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index 15b3b2b5..71b9b7aa 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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" diff --git a/app/src/main/java/xyz/quaver/pupil/Pupil.kt b/app/src/main/java/xyz/quaver/pupil/Pupil.kt index 7e4e1f57..b0cea2e5 100644 --- a/app/src/main/java/xyz/quaver/pupil/Pupil.kt +++ b/app/src/main/java/xyz/quaver/pupil/Pupil.kt @@ -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 private set @@ -65,8 +59,6 @@ lateinit var favoriteTags: SavedSet lateinit var searchHistory: SavedSet private set -val interceptors = mutableMapOf, 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() with provider { client } + bind() with singleton { ImageCache(this@Pupil) } + bind() 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("download_folder").also { diff --git a/app/src/main/java/xyz/quaver/pupil/adapters/ReaderAdapter.kt b/app/src/main/java/xyz/quaver/pupil/adapters/ReaderAdapter.kt index de747baa..2aac800d 100644 --- a/app/src/main/java/xyz/quaver/pupil/adapters/ReaderAdapter.kt +++ b/app/src/main/java/xyz/quaver/pupil/adapters/ReaderAdapter.kt @@ -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() { +data class ReaderItem( + val progress: Float, + val image: File? +) + +class ReaderAdapter : ListAdapter(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() { + 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() { + 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 + } + } } \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/adapters/SearchResultsAdapter.kt b/app/src/main/java/xyz/quaver/pupil/adapters/SearchResultsAdapter.kt index eedcc9d2..0bccd261 100644 --- a/app/src/main/java/xyz/quaver/pupil/adapters/SearchResultsAdapter.kt +++ b/app/src/main/java/xyz/quaver/pupil/adapters/SearchResultsAdapter.kt @@ -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) : RecyclerSwipeAdapter(), 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) : 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) : 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) : RecyclerSwipeA ProgressCardView.Type.CACHE } else ProgressCardView.Type.LOADING + */ } @SuppressLint("SetTextI18n") diff --git a/app/src/main/java/xyz/quaver/pupil/services/DownloadService.kt b/app/src/main/java/xyz/quaver/pupil/services/DownloadService.kt deleted file mode 100644 index d0c48929..00000000 --- a/app/src/main/java/xyz/quaver/pupil/services/DownloadService.kt +++ /dev/null @@ -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 . - */ - -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() - - 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>() - 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) - } -} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/sources/Common.kt b/app/src/main/java/xyz/quaver/pupil/sources/Common.kt index 09ad6432..f9306c89 100644 --- a/app/src/main/java/xyz/quaver/pupil/sources/Common.kt +++ b/app/src/main/java/xyz/quaver/pupil/sources/Common.kt @@ -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, Suggestion: SearchSuggestion> { abstract val name: String abstract val iconResID: Int @@ -117,10 +122,10 @@ abstract class Source, Suggestion: SearchSu abstract suspend fun search(query: String, range: IntRange, sortMode: Enum<*>) : Pair, Int> abstract suspend fun suggestion(query: String) : List - abstract suspend fun images(id: String) : List - abstract suspend fun info(id: String) : ItemInfo + abstract suspend fun images(itemID: String) : List + abstract suspend fun info(itemID: String) : ItemInfo - open fun getHeadersForImage(id: String, url: String): Map { + open fun getHeadersForImage(itemID: String, url: String): Map { return emptyMap() } @@ -129,9 +134,21 @@ abstract class Source, Suggestion: SearchSu } } -val sources = mutableMapOf>() +@Deprecated("") +val sources = mutableMapOf() val sourceIcons = mutableMapOf() +@Suppress("UNCHECKED_CAST") +val sourceModule = DI.Module(name = "source") { + listOf( + Hitomi(), + Hiyobi() + ).forEach { + bind(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) } } \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/sources/Hitomi.kt b/app/src/main/java/xyz/quaver/pupil/sources/Hitomi.kt index 40c089cc..845e2553 100644 --- a/app/src/main/java/xyz/quaver/pupil/sources/Hitomi.kt +++ b/app/src/main/java/xyz/quaver/pupil/sources/Hitomi.kt @@ -98,8 +98,8 @@ class Hitomi : Source() { } } - override suspend fun images(id: String): List { - val galleryID = id.toInt() + override suspend fun images(itemID: String): List { + val galleryID = itemID.toInt() val reader = getGalleryInfo(galleryID) @@ -108,32 +108,36 @@ class Hitomi : Source() { } } - 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 { + override fun getHeadersForImage(itemID: String, url: String): Map { return mapOf( - "Referer" to getReferer(id.toInt()) + "Referer" to getReferer(itemID.toInt()) ) } diff --git a/app/src/main/java/xyz/quaver/pupil/sources/Hiyobi.kt b/app/src/main/java/xyz/quaver/pupil/sources/Hiyobi.kt index e1c084ba..f3d2d6a8 100644 --- a/app/src/main/java/xyz/quaver/pupil/sources/Hiyobi.kt +++ b/app/src/main/java/xyz/quaver/pupil/sources/Hiyobi.kt @@ -72,14 +72,14 @@ class Hiyobi : Source() { return result.map { DefaultSearchSuggestion(it) } } - override suspend fun images(id: String): List { - return createImgList(id, getGalleryInfo(id), true).map { + override suspend fun images(itemID: String): List { + 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() { } companion object { - private fun downloadAllTags(): Deferred> = CoroutineScope(Dispatchers.IO).async { + private fun downloadAllTagsAsync(): Deferred> = 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() { private var _allTags: Deferred>? = null val allTags: Deferred> - 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!! diff --git a/app/src/main/java/xyz/quaver/pupil/ui/MainActivity.kt b/app/src/main/java/xyz/quaver/pupil/ui/MainActivity.kt index 8edc5a5c..f56442d2 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/MainActivity.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/MainActivity.kt @@ -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() } diff --git a/app/src/main/java/xyz/quaver/pupil/ui/ReaderActivity.kt b/app/src/main/java/xyz/quaver/pupil/ui/ReaderActivity.kt index dbee4c91..5ee5785d 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/ReaderActivity.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/ReaderActivity.kt @@ -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(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) } } \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/ui/dialog/DownloadLocationDialogFragment.kt b/app/src/main/java/xyz/quaver/pupil/ui/dialog/DownloadLocationDialogFragment.kt index b6200576..54e9e8ed 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/dialog/DownloadLocationDialogFragment.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/dialog/DownloadLocationDialogFragment.kt @@ -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 diff --git a/app/src/main/java/xyz/quaver/pupil/ui/dialog/GalleryDialog.kt b/app/src/main/java/xyz/quaver/pupil/ui/dialog/GalleryDialog.kt index 17ce7af7..21ad0e3f 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/dialog/GalleryDialog.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/dialog/GalleryDialog.kt @@ -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 diff --git a/app/src/main/java/xyz/quaver/pupil/ui/dialog/MirrorDialog.kt b/app/src/main/java/xyz/quaver/pupil/ui/dialog/MirrorDialog.kt deleted file mode 100644 index aafb58d0..00000000 --- a/app/src/main/java/xyz/quaver/pupil/ui/dialog/MirrorDialog.kt +++ /dev/null @@ -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 . - */ - -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(">") - } - } - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/ui/fragment/ManageStorageFragment.kt b/app/src/main/java/xyz/quaver/pupil/ui/fragment/ManageStorageFragment.kt index 4efa9357..2831beec 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/fragment/ManageStorageFragment.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/fragment/ManageStorageFragment.kt @@ -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("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("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() diff --git a/app/src/main/java/xyz/quaver/pupil/ui/fragment/SettingsFragment.kt b/app/src/main/java/xyz/quaver/pupil/ui/fragment/SettingsFragment.kt index 530a8af1..8e77bbf3 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/fragment/SettingsFragment.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/fragment/SettingsFragment.kt @@ -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 diff --git a/app/src/main/java/xyz/quaver/pupil/ui/viewmodel/ReaderViewModel.kt b/app/src/main/java/xyz/quaver/pupil/ui/viewmodel/ReaderViewModel.kt new file mode 100644 index 00000000..68c8b024 --- /dev/null +++ b/app/src/main/java/xyz/quaver/pupil/ui/viewmodel/ReaderViewModel.kt @@ -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 . + */ + + +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() + val title = _title as LiveData + + private val _images = MutableLiveData>() + val images: LiveData> = _images + + private var _readerItems = MutableLiveData>() + val readerItems = _readerItems as LiveData> + + @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) } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/util/downloader/DownloadManager.kt b/app/src/main/java/xyz/quaver/pupil/util/DownloadManager.kt similarity index 79% rename from app/src/main/java/xyz/quaver/pupil/util/downloader/DownloadManager.kt rename to app/src/main/java/xyz/quaver/pupil/util/DownloadManager.kt index 2ece9e06..83b6ffc5 100644 --- a/app/src/main/java/xyz/quaver/pupil/util/downloader/DownloadManager.kt +++ b/app/src/main/java/xyz/quaver/pupil/util/DownloadManager.kt @@ -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 . */ -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? = null - val downloadFolderMap: MutableMap + private val downloadFolderMap: MutableMap @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() diff --git a/app/src/main/java/xyz/quaver/pupil/util/ImageCache.kt b/app/src/main/java/xyz/quaver/pupil/util/ImageCache.kt new file mode 100644 index 00000000..f5b665da --- /dev/null +++ b/app/src/main/java/xyz/quaver/pupil/util/ImageCache.kt @@ -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 . + */ + +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>() + val channels = _channels as Map> + + @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) { + 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(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 } + } +} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/util/SavedCollections.kt b/app/src/main/java/xyz/quaver/pupil/util/SavedCollections.kt new file mode 100644 index 00000000..cbe83889 --- /dev/null +++ b/app/src/main/java/xyz/quaver/pupil/util/SavedCollections.kt @@ -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 . + */ + +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 (private val file: File, any: T, private val set: MutableSet = mutableSetOf()) : MutableSet by set { + + @Suppress("UNCHECKED_CAST") + @OptIn(ExperimentalSerializationApi::class) + val serializer: KSerializer> = SetSerializer(serializer(any::class.java) as KSerializer) + + 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): 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 (private val file: File, anyKey: K, anyValue: V, private val map: MutableMap = mutableMapOf()) : MutableMap by map { + + @Suppress("UNCHECKED_CAST") + @OptIn(ExperimentalSerializationApi::class) + val serializer: KSerializer> = MapSerializer(serializer(anyKey::class.java) as KSerializer, serializer(anyValue::class.java) as KSerializer) + + 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) { + 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() + } + +} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/util/SavedSet.kt b/app/src/main/java/xyz/quaver/pupil/util/SavedSet.kt deleted file mode 100644 index 6e2c0987..00000000 --- a/app/src/main/java/xyz/quaver/pupil/util/SavedSet.kt +++ /dev/null @@ -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 . - */ - -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 (private val file: File, private val any: T, private val set: MutableSet = mutableSetOf()) : MutableSet by set { - - @Suppress("UNCHECKED_CAST") - @OptIn(ExperimentalSerializationApi::class) - val serializer: KSerializer> - get() = ListSerializer(serializer(any::class.java) as KSerializer) - - 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): 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() - } - -} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/util/downloader/Cache.kt b/app/src/main/java/xyz/quaver/pupil/util/downloader/Cache.kt deleted file mode 100644 index 5251b07b..00000000 --- a/app/src/main/java/xyz/quaver/pupil/util/downloader/Cache.kt +++ /dev/null @@ -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 . - */ - -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? = 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() - - 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(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) } - } -} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/util/downloader/Downloader.kt b/app/src/main/java/xyz/quaver/pupil/util/downloader/Downloader.kt deleted file mode 100644 index e3d001ff..00000000 --- a/app/src/main/java/xyz/quaver/pupil/util/downloader/Downloader.kt +++ /dev/null @@ -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 . - */ - -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() - - 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>() - fun getProgress(source: String, itemID: String): List? { - 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) -> 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 - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/util/file.kt b/app/src/main/java/xyz/quaver/pupil/util/file.kt index f51cd111..c7dcf2be 100644 --- a/app/src/main/java/xyz/quaver/pupil/util/file.kt +++ b/app/src/main/java/xyz/quaver/pupil/util/file.kt @@ -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("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() - } - } - } - } -} \ No newline at end of file +fun File.size(): Long = + this.walk().fold(0L) { size, file -> size + file.length() } \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/util/misc.kt b/app/src/main/java/xyz/quaver/pupil/util/misc.kt index 3bdba17d..50aafc39 100644 --- a/app/src/main/java/xyz/quaver/pupil/util/misc.kt +++ b/app/src/main/java/xyz/quaver/pupil/util/misc.kt @@ -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)>( /** * 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 - 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.findMenu(itemID: Int): MenuItem { return first { it.itemId == itemID } +} + +fun MutableLiveData>.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 } \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/util/update.kt b/app/src/main/java/xyz/quaver/pupil/util/update.kt index 7f755973..2973531a 100644 --- a/app/src/main/java/xyz/quaver/pupil/util/update.kt +++ b/app/src/main/java/xyz/quaver/pupil/util/update.kt @@ -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 diff --git a/app/src/main/res/layout/numberpicker_dialog.xml b/app/src/main/res/layout/numberpicker_dialog.xml deleted file mode 100644 index e5687cb5..00000000 --- a/app/src/main/res/layout/numberpicker_dialog.xml +++ /dev/null @@ -1,53 +0,0 @@ - - - - - - - - - -