Merge remote-tracking branch 'origin/devel' into devel
# Conflicts: # app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt # app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupWorker.ktmaster
commit
30e43d3bfe
@ -0,0 +1,73 @@
|
|||||||
|
package org.koitharu.kotatsu
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.Notification.BigTextStyle
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.StrictMode
|
||||||
|
import android.os.strictmode.Violation
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.core.app.PendingIntentCompat
|
||||||
|
import androidx.core.content.getSystemService
|
||||||
|
import androidx.fragment.app.strictmode.FragmentStrictMode
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.asExecutor
|
||||||
|
import org.koitharu.kotatsu.core.util.ShareHelper
|
||||||
|
import kotlin.math.absoluteValue
|
||||||
|
import androidx.fragment.app.strictmode.Violation as FragmentViolation
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.P)
|
||||||
|
class StrictModeNotifier(
|
||||||
|
private val context: Context,
|
||||||
|
) : StrictMode.OnVmViolationListener, StrictMode.OnThreadViolationListener, FragmentStrictMode.OnViolationListener {
|
||||||
|
|
||||||
|
val executor = Dispatchers.Default.asExecutor()
|
||||||
|
|
||||||
|
private val notificationManager by lazy {
|
||||||
|
val nm = checkNotNull(context.getSystemService<NotificationManager>())
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
context.getString(R.string.strict_mode),
|
||||||
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
|
)
|
||||||
|
nm.createNotificationChannel(channel)
|
||||||
|
nm
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onVmViolation(v: Violation) = showNotification(v)
|
||||||
|
|
||||||
|
override fun onThreadViolation(v: Violation) = showNotification(v)
|
||||||
|
|
||||||
|
override fun onViolation(violation: FragmentViolation) = showNotification(violation)
|
||||||
|
|
||||||
|
private fun showNotification(violation: Throwable) = Notification.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_bug)
|
||||||
|
.setContentTitle(context.getString(R.string.strict_mode))
|
||||||
|
.setContentText(violation.message)
|
||||||
|
.setStyle(
|
||||||
|
BigTextStyle()
|
||||||
|
.setBigContentTitle(context.getString(R.string.strict_mode))
|
||||||
|
.setSummaryText(violation.message)
|
||||||
|
.bigText(violation.stackTraceToString()),
|
||||||
|
).setShowWhen(true)
|
||||||
|
.setContentIntent(
|
||||||
|
PendingIntentCompat.getActivity(
|
||||||
|
context,
|
||||||
|
0,
|
||||||
|
ShareHelper(context).getShareTextIntent(violation.stackTraceToString()),
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setGroup(CHANNEL_ID)
|
||||||
|
.build()
|
||||||
|
.let { notificationManager.notify(CHANNEL_ID, violation.hashCode().absoluteValue, it) }
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
|
||||||
|
const val CHANNEL_ID = "strict_mode"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="#FFFFFF">
|
||||||
|
<group android:scaleX="0.98150784"
|
||||||
|
android:scaleY="0.98150784"
|
||||||
|
android:translateX="0.22190611"
|
||||||
|
android:translateY="-0.2688478">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M20,8h-2.81c-0.45,-0.78 -1.07,-1.45 -1.82,-1.96L17,4.41 15.59,3l-2.17,2.17C12.96,5.06 12.49,5 12,5c-0.49,0 -0.96,0.06 -1.41,0.17L8.41,3 7,4.41l1.62,1.63C7.88,6.55 7.26,7.22 6.81,8L4,8v2h2.09c-0.05,0.33 -0.09,0.66 -0.09,1v1L4,12v2h2v1c0,0.34 0.04,0.67 0.09,1L4,16v2h2.81c1.04,1.79 2.97,3 5.19,3s4.15,-1.21 5.19,-3L20,18v-2h-2.09c0.05,-0.33 0.09,-0.66 0.09,-1v-1h2v-2h-2v-1c0,-0.34 -0.04,-0.67 -0.09,-1L20,10L20,8zM14,16h-4v-2h4v2zM14,12h-4v-2h4v2z"/>
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 417 B |
Binary file not shown.
|
After Width: | Height: | Size: 308 B |
Binary file not shown.
|
After Width: | Height: | Size: 480 B |
Binary file not shown.
|
After Width: | Height: | Size: 792 B |
@ -1,3 +1,4 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name" translatable="false">Kotatsu Dev</string>
|
<string name="app_name" translatable="false">Kotatsu Dev</string>
|
||||||
|
<string name="strict_mode">Strict mode</string>
|
||||||
</resources>
|
</resources>
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
package org.koitharu.kotatsu.core.backup
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
data class BackupFile(
|
||||||
|
val uri: Uri,
|
||||||
|
val dateTime: Date,
|
||||||
|
): Comparable<BackupFile> {
|
||||||
|
|
||||||
|
override fun compareTo(other: BackupFile): Int = compareValues(dateTime, other.dateTime)
|
||||||
|
}
|
||||||
@ -0,0 +1,75 @@
|
|||||||
|
package org.koitharu.kotatsu.core.backup
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
|
import okio.IOException
|
||||||
|
import okio.buffer
|
||||||
|
import okio.sink
|
||||||
|
import okio.source
|
||||||
|
import org.jetbrains.annotations.Blocking
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class ExternalBackupStorage @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val settings: AppSettings,
|
||||||
|
) {
|
||||||
|
|
||||||
|
suspend fun list(): List<BackupFile> = runInterruptible(Dispatchers.IO) {
|
||||||
|
getRoot().listFiles().mapNotNull {
|
||||||
|
if (it.isFile && it.canRead()) {
|
||||||
|
BackupFile(
|
||||||
|
uri = it.uri,
|
||||||
|
dateTime = it.name?.let { fileName ->
|
||||||
|
BackupZipOutput.parseBackupDateTime(fileName)
|
||||||
|
} ?: return@mapNotNull null,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}.sortedDescending()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun put(file: File): Uri = runInterruptible(Dispatchers.IO) {
|
||||||
|
val out = checkNotNull(getRoot().createFile("application/zip", file.nameWithoutExtension)) {
|
||||||
|
"Cannot create target backup file"
|
||||||
|
}
|
||||||
|
checkNotNull(context.contentResolver.openOutputStream(out.uri, "wt")).sink().use { sink ->
|
||||||
|
file.source().buffer().use { src ->
|
||||||
|
src.readAll(sink)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.uri
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun delete(victim: BackupFile) = runInterruptible(Dispatchers.IO) {
|
||||||
|
val df = checkNotNull(DocumentFile.fromSingleUri(context, victim.uri)) {
|
||||||
|
"${victim.uri} cannot be resolved to the DocumentFile"
|
||||||
|
}
|
||||||
|
if (!df.delete()) {
|
||||||
|
throw IOException("Cannot delete ${df.uri}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getLastBackupDate() = list().maxByOrNull { it.dateTime }?.dateTime
|
||||||
|
|
||||||
|
suspend fun trim(maxCount: Int) {
|
||||||
|
list().drop(maxCount).forEach {
|
||||||
|
delete(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Blocking
|
||||||
|
private fun getRoot(): DocumentFile {
|
||||||
|
val uri = checkNotNull(settings.periodicalBackupDirectory) {
|
||||||
|
"Backup directory is not specified"
|
||||||
|
}
|
||||||
|
val root = DocumentFile.fromTreeUri(context, uri)
|
||||||
|
return checkNotNull(root) { "Cannot obtain DocumentFile from $uri" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,20 +1,31 @@
|
|||||||
package org.koitharu.kotatsu.core.fs
|
package org.koitharu.kotatsu.core.fs
|
||||||
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import org.koitharu.kotatsu.core.util.iterator.CloseableIterator
|
import androidx.annotation.RequiresApi
|
||||||
|
import org.koitharu.kotatsu.core.util.CloseableSequence
|
||||||
import org.koitharu.kotatsu.core.util.iterator.MappingIterator
|
import org.koitharu.kotatsu.core.util.iterator.MappingIterator
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
|
|
||||||
class FileSequence(private val dir: File) : Sequence<File> {
|
sealed interface FileSequence : CloseableSequence<File> {
|
||||||
|
|
||||||
override fun iterator(): Iterator<File> {
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
class StreamImpl(dir: File) : FileSequence {
|
||||||
val stream = Files.newDirectoryStream(dir.toPath())
|
|
||||||
CloseableIterator(MappingIterator(stream.iterator(), Path::toFile), stream)
|
private val stream = Files.newDirectoryStream(dir.toPath())
|
||||||
} else {
|
|
||||||
dir.listFiles().orEmpty().iterator()
|
override fun iterator(): Iterator<File> = MappingIterator(stream.iterator(), Path::toFile)
|
||||||
|
|
||||||
|
override fun close() = stream.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ListImpl(dir: File) : FileSequence {
|
||||||
|
|
||||||
|
private val list = dir.listFiles().orEmpty()
|
||||||
|
|
||||||
|
override fun iterator(): Iterator<File> = list.iterator()
|
||||||
|
|
||||||
|
override fun close() = Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,66 @@
|
|||||||
|
package org.koitharu.kotatsu.core.image
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import coil3.ImageLoader
|
||||||
|
import coil3.asImage
|
||||||
|
import coil3.decode.DecodeResult
|
||||||
|
import coil3.decode.Decoder
|
||||||
|
import coil3.decode.ImageSource
|
||||||
|
import coil3.fetch.SourceFetchResult
|
||||||
|
import coil3.request.Options
|
||||||
|
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
|
import org.aomedia.avif.android.AvifDecoder
|
||||||
|
import org.aomedia.avif.android.AvifDecoder.Info
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.toByteBuffer
|
||||||
|
|
||||||
|
class AvifImageDecoder(
|
||||||
|
private val source: ImageSource,
|
||||||
|
private val options: Options,
|
||||||
|
) : Decoder {
|
||||||
|
|
||||||
|
override suspend fun decode(): DecodeResult = runInterruptible {
|
||||||
|
val bytes = source.source().use {
|
||||||
|
it.inputStream().toByteBuffer()
|
||||||
|
}
|
||||||
|
val info = Info()
|
||||||
|
if (!AvifDecoder.getInfo(bytes, bytes.remaining(), info)) {
|
||||||
|
throw ImageDecodeException(
|
||||||
|
null,
|
||||||
|
"avif",
|
||||||
|
"Requested to decode byte buffer which cannot be handled by AvifDecoder",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val config = if (info.depth == 8 || info.alphaPresent) Bitmap.Config.ARGB_8888 else Bitmap.Config.RGB_565
|
||||||
|
val bitmap = Bitmap.createBitmap(info.width, info.height, config)
|
||||||
|
if (!AvifDecoder.decode(bytes, bytes.remaining(), bitmap)) {
|
||||||
|
bitmap.recycle()
|
||||||
|
throw ImageDecodeException(null, "avif")
|
||||||
|
}
|
||||||
|
DecodeResult(
|
||||||
|
image = bitmap.asImage(),
|
||||||
|
isSampled = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
class Factory : Decoder.Factory {
|
||||||
|
|
||||||
|
override fun create(
|
||||||
|
result: SourceFetchResult,
|
||||||
|
options: Options,
|
||||||
|
imageLoader: ImageLoader
|
||||||
|
): Decoder? = if (isApplicable(result)) {
|
||||||
|
AvifImageDecoder(result.source, options)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun equals(other: Any?) = other is Factory
|
||||||
|
|
||||||
|
override fun hashCode() = javaClass.hashCode()
|
||||||
|
|
||||||
|
private fun isApplicable(result: SourceFetchResult): Boolean {
|
||||||
|
return result.mimeType == "image/avif"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,77 @@
|
|||||||
|
package org.koitharu.kotatsu.core.image
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.ImageDecoder
|
||||||
|
import android.os.Build
|
||||||
|
import android.webkit.MimeTypeMap
|
||||||
|
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
|
||||||
|
import okhttp3.MediaType
|
||||||
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||||
|
import org.aomedia.avif.android.AvifDecoder
|
||||||
|
import org.aomedia.avif.android.AvifDecoder.Info
|
||||||
|
import org.jetbrains.annotations.Blocking
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.toByteBuffer
|
||||||
|
import java.io.File
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.file.Files
|
||||||
|
|
||||||
|
object BitmapDecoderCompat {
|
||||||
|
|
||||||
|
private const val FORMAT_AVIF = "avif"
|
||||||
|
|
||||||
|
@Blocking
|
||||||
|
fun decode(file: File): Bitmap = when (val format = getMimeType(file)?.subtype) {
|
||||||
|
FORMAT_AVIF -> file.inputStream().use { decodeAvif(it.toByteBuffer()) }
|
||||||
|
else -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
ImageDecoder.decodeBitmap(ImageDecoder.createSource(file))
|
||||||
|
} else {
|
||||||
|
checkBitmapNotNull(BitmapFactory.decodeFile(file.absolutePath), format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Blocking
|
||||||
|
fun decode(stream: InputStream, type: MediaType?): Bitmap {
|
||||||
|
val format = type?.subtype
|
||||||
|
if (format == FORMAT_AVIF) {
|
||||||
|
return decodeAvif(stream.toByteBuffer())
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
|
||||||
|
return checkBitmapNotNull(BitmapFactory.decodeStream(stream), format)
|
||||||
|
}
|
||||||
|
val byteBuffer = stream.toByteBuffer()
|
||||||
|
return if (AvifDecoder.isAvifImage(byteBuffer)) {
|
||||||
|
decodeAvif(byteBuffer)
|
||||||
|
} else {
|
||||||
|
ImageDecoder.decodeBitmap(ImageDecoder.createSource(byteBuffer))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getMimeType(file: File): MediaType? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
Files.probeContentType(file.toPath())?.toMediaTypeOrNull()
|
||||||
|
} else {
|
||||||
|
MimeTypeMap.getSingleton().getMimeTypeFromExtension(file.extension)?.toMediaTypeOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkBitmapNotNull(bitmap: Bitmap?, format: String?): Bitmap =
|
||||||
|
bitmap ?: throw ImageDecodeException(null, format)
|
||||||
|
|
||||||
|
private fun decodeAvif(bytes: ByteBuffer): Bitmap {
|
||||||
|
val info = Info()
|
||||||
|
if (!AvifDecoder.getInfo(bytes, bytes.remaining(), info)) {
|
||||||
|
throw ImageDecodeException(
|
||||||
|
null,
|
||||||
|
FORMAT_AVIF,
|
||||||
|
"Requested to decode byte buffer which cannot be handled by AvifDecoder",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val config = if (info.depth == 8 || info.alphaPresent) Bitmap.Config.ARGB_8888 else Bitmap.Config.RGB_565
|
||||||
|
val bitmap = Bitmap.createBitmap(info.width, info.height, config)
|
||||||
|
if (!AvifDecoder.decode(bytes, bytes.remaining(), bitmap)) {
|
||||||
|
bitmap.recycle()
|
||||||
|
throw ImageDecodeException(null, FORMAT_AVIF)
|
||||||
|
}
|
||||||
|
return bitmap
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
package org.koitharu.kotatsu.core.image
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import android.webkit.MimeTypeMap
|
||||||
|
import coil3.ImageLoader
|
||||||
|
import coil3.decode.DataSource
|
||||||
|
import coil3.decode.ImageSource
|
||||||
|
import coil3.fetch.Fetcher
|
||||||
|
import coil3.fetch.SourceFetchResult
|
||||||
|
import coil3.request.Options
|
||||||
|
import coil3.toAndroidUri
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
|
import okio.Path.Companion.toPath
|
||||||
|
import okio.openZip
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.isZipUri
|
||||||
|
import coil3.Uri as CoilUri
|
||||||
|
|
||||||
|
class CbzFetcher(
|
||||||
|
private val uri: Uri,
|
||||||
|
private val options: Options,
|
||||||
|
) : Fetcher {
|
||||||
|
|
||||||
|
override suspend fun fetch() = runInterruptible {
|
||||||
|
val filePath = uri.schemeSpecificPart.toPath()
|
||||||
|
val entryName = requireNotNull(uri.fragment)
|
||||||
|
SourceFetchResult(
|
||||||
|
source = ImageSource(entryName.toPath(), options.fileSystem.openZip(filePath)),
|
||||||
|
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(entryName.substringAfterLast('.', "")),
|
||||||
|
dataSource = DataSource.DISK,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
class Factory : Fetcher.Factory<CoilUri> {
|
||||||
|
|
||||||
|
override fun create(
|
||||||
|
data: CoilUri,
|
||||||
|
options: Options,
|
||||||
|
imageLoader: ImageLoader
|
||||||
|
): Fetcher? {
|
||||||
|
val androidUri = data.toAndroidUri()
|
||||||
|
return if (androidUri.isZipUri()) {
|
||||||
|
CbzFetcher(androidUri, options)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
package org.koitharu.kotatsu.core.image
|
||||||
|
|
||||||
|
import coil3.intercept.Interceptor
|
||||||
|
import coil3.network.httpHeaders
|
||||||
|
import coil3.request.ImageResult
|
||||||
|
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.mangaSourceKey
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||||
|
|
||||||
|
class MangaSourceHeaderInterceptor : Interceptor {
|
||||||
|
|
||||||
|
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
|
||||||
|
val mangaSource = chain.request.extras[mangaSourceKey] as? MangaParserSource ?: return chain.proceed()
|
||||||
|
val request = chain.request
|
||||||
|
val newHeaders = request.httpHeaders.newBuilder()
|
||||||
|
.set(CommonHeaders.MANGA_SOURCE, mangaSource.name)
|
||||||
|
.build()
|
||||||
|
val newRequest = request.newBuilder()
|
||||||
|
.httpHeaders(newHeaders)
|
||||||
|
.build()
|
||||||
|
return chain.withRequest(newRequest).proceed()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
package org.koitharu.kotatsu.core.io
|
||||||
|
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.util.Objects
|
||||||
|
|
||||||
|
class NullOutputStream : OutputStream() {
|
||||||
|
|
||||||
|
override fun write(b: Int) = Unit
|
||||||
|
|
||||||
|
override fun write(b: ByteArray, off: Int, len: Int) {
|
||||||
|
Objects.checkFromIndexSize(off, len, b.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
package org.koitharu.kotatsu.core.prefs
|
||||||
|
|
||||||
|
import androidx.annotation.Keep
|
||||||
|
|
||||||
|
@Keep
|
||||||
|
enum class TriStateOption {
|
||||||
|
|
||||||
|
ENABLED, ASK, DISABLED;
|
||||||
|
}
|
||||||
@ -0,0 +1,58 @@
|
|||||||
|
package org.koitharu.kotatsu.core.ui.dialog
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.DialogInterface
|
||||||
|
import androidx.annotation.UiContext
|
||||||
|
import androidx.core.net.ConnectivityManagerCompat
|
||||||
|
import dagger.Lazy
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.core.prefs.TriStateOption
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.connectivityManager
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class CommonAlertDialogs @Inject constructor(
|
||||||
|
private val settings: Lazy<AppSettings>,
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun askForDownloadOverMeteredNetwork(
|
||||||
|
@UiContext context: Context,
|
||||||
|
onConfirmed: (allow: Boolean) -> Unit
|
||||||
|
) {
|
||||||
|
when (settings.get().allowDownloadOnMeteredNetwork) {
|
||||||
|
TriStateOption.ENABLED -> onConfirmed(true)
|
||||||
|
TriStateOption.DISABLED -> onConfirmed(false)
|
||||||
|
TriStateOption.ASK -> {
|
||||||
|
if (!ConnectivityManagerCompat.isActiveNetworkMetered(context.connectivityManager)) {
|
||||||
|
onConfirmed(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val listener = DialogInterface.OnClickListener { _, which ->
|
||||||
|
when (which) {
|
||||||
|
DialogInterface.BUTTON_POSITIVE -> {
|
||||||
|
settings.get().allowDownloadOnMeteredNetwork = TriStateOption.ENABLED
|
||||||
|
onConfirmed(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
DialogInterface.BUTTON_NEUTRAL -> {
|
||||||
|
onConfirmed(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
DialogInterface.BUTTON_NEGATIVE -> {
|
||||||
|
settings.get().allowDownloadOnMeteredNetwork = TriStateOption.DISABLED
|
||||||
|
onConfirmed(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BigButtonsAlertDialog.Builder(context)
|
||||||
|
.setIcon(R.drawable.ic_network_cellular)
|
||||||
|
.setTitle(R.string.download_cellular_confirm)
|
||||||
|
.setPositiveButton(R.string.allow_always, listener)
|
||||||
|
.setNeutralButton(R.string.allow_once, listener)
|
||||||
|
.setNegativeButton(R.string.dont_allow, listener)
|
||||||
|
.create()
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
package org.koitharu.kotatsu.core.util
|
||||||
|
|
||||||
|
interface CloseableSequence<T> : Sequence<T>, AutoCloseable
|
||||||
@ -1,56 +1,38 @@
|
|||||||
package org.koitharu.kotatsu.core.util.ext
|
package org.koitharu.kotatsu.core.util.ext
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.core.net.toFile
|
import androidx.core.net.toUri
|
||||||
import okio.Source
|
import okio.Path
|
||||||
import okio.source
|
|
||||||
import okio.use
|
|
||||||
import org.jetbrains.annotations.Blocking
|
|
||||||
import org.koitharu.kotatsu.local.data.util.withExtraCloseable
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.zip.ZipFile
|
|
||||||
|
|
||||||
const val URI_SCHEME_FILE = "file"
|
|
||||||
const val URI_SCHEME_ZIP = "file+zip"
|
const val URI_SCHEME_ZIP = "file+zip"
|
||||||
|
private const val URI_SCHEME_FILE = "file"
|
||||||
@Blocking
|
private const val URI_SCHEME_HTTP = "http"
|
||||||
fun Uri.exists(): Boolean = when (scheme) {
|
private const val URI_SCHEME_HTTPS = "https"
|
||||||
URI_SCHEME_FILE -> toFile().exists()
|
private const val URI_SCHEME_LEGACY_CBZ = "cbz"
|
||||||
URI_SCHEME_ZIP -> {
|
private const val URI_SCHEME_LEGACY_ZIP = "zip"
|
||||||
val file = File(requireNotNull(schemeSpecificPart))
|
|
||||||
file.exists() && ZipFile(file).use { it.getEntry(fragment) != null }
|
fun Uri.isZipUri() = scheme.let {
|
||||||
}
|
it == URI_SCHEME_ZIP || it == URI_SCHEME_LEGACY_CBZ || it == URI_SCHEME_LEGACY_ZIP
|
||||||
|
|
||||||
else -> unsupportedUri(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Blocking
|
|
||||||
fun Uri.isTargetNotEmpty(): Boolean = when (scheme) {
|
|
||||||
URI_SCHEME_FILE -> toFile().isNotEmpty()
|
|
||||||
URI_SCHEME_ZIP -> {
|
|
||||||
val file = File(requireNotNull(schemeSpecificPart))
|
|
||||||
file.exists() && ZipFile(file).use { (it.getEntry(fragment)?.size ?: 0L) != 0L }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> unsupportedUri(this)
|
fun Uri.isFileUri() = scheme == URI_SCHEME_FILE
|
||||||
}
|
|
||||||
|
|
||||||
@Blocking
|
fun Uri.isNetworkUri() = scheme.let {
|
||||||
fun Uri.source(): Source = when (scheme) {
|
it == URI_SCHEME_HTTP || it == URI_SCHEME_HTTPS
|
||||||
URI_SCHEME_FILE -> toFile().source()
|
|
||||||
URI_SCHEME_ZIP -> {
|
|
||||||
val zip = ZipFile(schemeSpecificPart)
|
|
||||||
val entry = zip.getEntry(fragment)
|
|
||||||
zip.getInputStreamOrClose(entry).source().withExtraCloseable(zip)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> unsupportedUri(this)
|
fun File.toZipUri(entryPath: String): Uri = Uri.parse("$URI_SCHEME_ZIP://$absolutePath#$entryPath")
|
||||||
}
|
|
||||||
|
|
||||||
fun File.toZipUri(entryName: String): Uri = Uri.parse("$URI_SCHEME_ZIP://$absolutePath#$entryName")
|
fun File.toZipUri(entryPath: Path?): Uri =
|
||||||
|
toZipUri(entryPath?.toString()?.removePrefix(Path.DIRECTORY_SEPARATOR).orEmpty())
|
||||||
|
|
||||||
fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this)
|
fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this)
|
||||||
|
|
||||||
private fun unsupportedUri(uri: Uri): Nothing {
|
fun File.toUri(fragment: String?): Uri = toUri().run {
|
||||||
throw IllegalArgumentException("Bad uri $uri: only schemes $URI_SCHEME_FILE and $URI_SCHEME_ZIP are supported")
|
if (fragment != null) {
|
||||||
|
buildUpon().fragment(fragment).build()
|
||||||
|
} else {
|
||||||
|
this
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,36 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.util.iterator
|
|
||||||
|
|
||||||
import okhttp3.internal.closeQuietly
|
|
||||||
import okio.Closeable
|
|
||||||
|
|
||||||
class CloseableIterator<T>(
|
|
||||||
private val upstream: Iterator<T>,
|
|
||||||
private val closeable: Closeable,
|
|
||||||
) : Iterator<T>, Closeable {
|
|
||||||
|
|
||||||
private var isClosed = false
|
|
||||||
|
|
||||||
override fun hasNext(): Boolean {
|
|
||||||
val result = upstream.hasNext()
|
|
||||||
if (!result) {
|
|
||||||
close()
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun next(): T {
|
|
||||||
try {
|
|
||||||
return upstream.next()
|
|
||||||
} catch (e: NoSuchElementException) {
|
|
||||||
close()
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun close() {
|
|
||||||
if (!isClosed) {
|
|
||||||
closeable.closeQuietly()
|
|
||||||
isClosed = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue