Import local manga
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
package org.koitharu.kotatsu.core.exceptions
|
||||
|
||||
import java.io.IOException
|
||||
|
||||
class UnsupportedFileException(message: String? = null) : IOException(message)
|
||||
@@ -15,6 +15,7 @@ import org.koitharu.kotatsu.utils.ext.longHashCode
|
||||
import org.koitharu.kotatsu.utils.ext.readText
|
||||
import org.koitharu.kotatsu.utils.ext.safe
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
class LocalMangaRepository(loaderContext: MangaLoaderContext) : BaseMangaRepository(loaderContext) {
|
||||
@@ -92,6 +93,19 @@ class LocalMangaRepository(loaderContext: MangaLoaderContext) : BaseMangaReposit
|
||||
}
|
||||
}
|
||||
|
||||
fun delete(manga: Manga): Boolean {
|
||||
val file = Uri.parse(manga.url).toFile()
|
||||
return file.delete()
|
||||
}
|
||||
|
||||
private fun zipUri(file: File, entryName: String) =
|
||||
Uri.fromParts("cbz", file.path, entryName).toString()
|
||||
|
||||
companion object {
|
||||
|
||||
fun isFileSupported(name: String): Boolean {
|
||||
val ext = name.substringAfterLast('.').toLowerCase(Locale.ROOT)
|
||||
return ext == "cbz" || ext == "zip"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,7 @@ class HistoryListFragment : MangaListFragment<MangaHistory>(), MangaListView<Man
|
||||
Snackbar.make(
|
||||
recyclerView, getString(
|
||||
R.string._s_removed_from_history,
|
||||
item.title.ellipsize(14)
|
||||
item.title.ellipsize(16)
|
||||
), Snackbar.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
package org.koitharu.kotatsu.ui.main.list.local
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.android.synthetic.main.fragment_list.*
|
||||
import moxy.ktx.moxyPresenter
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.Manga
|
||||
import org.koitharu.kotatsu.ui.main.list.MangaListFragment
|
||||
import org.koitharu.kotatsu.utils.ext.ellipsize
|
||||
import java.io.File
|
||||
|
||||
class LocalListFragment : MangaListFragment<File>() {
|
||||
@@ -16,6 +26,33 @@ class LocalListFragment : MangaListFragment<File>() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||
inflater.inflate(R.menu.opt_local, menu)
|
||||
super.onCreateOptionsMenu(menu, inflater)
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
R.id.action_import -> {
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE)
|
||||
intent.type = "*/*"
|
||||
try {
|
||||
startActivityForResult(intent, REQUEST_IMPORT)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
Snackbar.make(
|
||||
recyclerView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
true
|
||||
}
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getTitle(): CharSequence? {
|
||||
return getString(R.string.local_storage)
|
||||
}
|
||||
@@ -25,8 +62,45 @@ class LocalListFragment : MangaListFragment<File>() {
|
||||
textView_holder.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0)
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
when (requestCode) {
|
||||
REQUEST_IMPORT -> if (resultCode == Activity.RESULT_OK) {
|
||||
val uri = data?.data ?: return
|
||||
presenter.importFile(context?.applicationContext ?: return, uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreatePopupMenu(inflater: MenuInflater, menu: Menu, data: Manga) {
|
||||
super.onCreatePopupMenu(inflater, menu, data)
|
||||
inflater.inflate(R.menu.popup_local, menu)
|
||||
}
|
||||
|
||||
override fun onPopupMenuItemSelected(item: MenuItem, data: Manga): Boolean {
|
||||
return when (item.itemId) {
|
||||
R.id.action_delete -> {
|
||||
presenter.delete(data)
|
||||
true
|
||||
}
|
||||
else -> super.onPopupMenuItemSelected(item, data)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onItemRemoved(item: Manga) {
|
||||
super.onItemRemoved(item)
|
||||
Snackbar.make(
|
||||
recyclerView, getString(
|
||||
R.string._s_deleted_from_local_storage,
|
||||
item.title.ellipsize(16)
|
||||
), Snackbar.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val REQUEST_IMPORT = 50
|
||||
|
||||
fun newInstance() = LocalListFragment()
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,29 @@
|
||||
package org.koitharu.kotatsu.ui.main.list.local
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import moxy.InjectViewState
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
|
||||
import org.koitharu.kotatsu.core.model.Manga
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.parser.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.domain.MangaProviderFactory
|
||||
import org.koitharu.kotatsu.domain.history.HistoryRepository
|
||||
import org.koitharu.kotatsu.ui.common.BasePresenter
|
||||
import org.koitharu.kotatsu.ui.main.list.MangaListView
|
||||
import org.koitharu.kotatsu.utils.MediaStoreCompat
|
||||
import org.koitharu.kotatsu.utils.ext.safe
|
||||
import org.koitharu.kotatsu.utils.ext.sub
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
@InjectViewState
|
||||
class LocalListPresenter : BasePresenter<MangaListView<File>>() {
|
||||
class LocalListPresenter : BasePresenter<MangaListView<File>>() {
|
||||
|
||||
private lateinit var repository: LocalMangaRepository
|
||||
|
||||
@@ -30,6 +40,7 @@ class LocalListPresenter : BasePresenter<MangaListView<File>>() {
|
||||
repository.getList(0)
|
||||
}
|
||||
viewState.onListChanged(list)
|
||||
} catch (e: CancellationException) {
|
||||
} catch (e: Exception) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
e.printStackTrace()
|
||||
@@ -40,4 +51,54 @@ class LocalListPresenter : BasePresenter<MangaListView<File>>() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun importFile(context: Context, uri: Uri) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val name = MediaStoreCompat.getName(context, uri)
|
||||
?: throw IOException("Cannot fetch name from uri: $uri")
|
||||
if (!LocalMangaRepository.isFileSupported(name)) {
|
||||
throw UnsupportedFileException("Unsupported file on $uri")
|
||||
}
|
||||
val dest = context.getExternalFilesDir("manga")?.sub(name)
|
||||
?: throw IOException("External files dir unavailable")
|
||||
context.contentResolver.openInputStream(uri)?.use { source ->
|
||||
dest.outputStream().use { output ->
|
||||
source.copyTo(output)
|
||||
}
|
||||
} ?: throw IOException("Cannot open input stream: $uri")
|
||||
val list = repository.getList(0)
|
||||
withContext(Dispatchers.Main) {
|
||||
viewState.onListChanged(list)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
} catch (e: Exception) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
viewState.onError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun delete(manga: Manga) {
|
||||
launch {
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
repository.delete(manga) || throw IOException("Unable to delete file")
|
||||
safe {
|
||||
HistoryRepository().delete(manga)
|
||||
}
|
||||
}
|
||||
viewState.onItemRemoved(manga)
|
||||
} catch (e: CancellationException) {
|
||||
} catch (e: Exception) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,13 @@ package org.koitharu.kotatsu.utils
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.provider.OpenableColumns
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.core.database.getStringOrNull
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import java.io.OutputStream
|
||||
|
||||
@@ -51,4 +54,17 @@ object MediaStoreCompat {
|
||||
}
|
||||
return uri
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getName(context: Context, uri: Uri): String? = (if (uri.scheme == "content") {
|
||||
context.contentResolver.query(uri, null, null, null, null)?.use {
|
||||
if (it.moveToFirst()) {
|
||||
it.getStringOrNull(it.getColumnIndex(OpenableColumns.DISPLAY_NAME))
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}) ?: uri.path?.substringAfterLast('/')
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import android.content.res.Resources
|
||||
import kotlinx.coroutines.delay
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
|
||||
import java.io.IOException
|
||||
|
||||
inline fun <T, R> T.safe(action: T.() -> R?) = try {
|
||||
@@ -32,6 +33,7 @@ suspend inline fun <T, R> T.retryUntilSuccess(maxAttempts: Int, action: T.() ->
|
||||
}
|
||||
|
||||
fun Throwable.getDisplayMessage(resources: Resources) = when (this) {
|
||||
is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
|
||||
is IOException -> resources.getString(R.string.network_error)
|
||||
else -> if (BuildConfig.DEBUG) {
|
||||
message ?: resources.getString(R.string.error_occurred)
|
||||
|
||||
12
app/src/main/res/menu/opt_local.xml
Normal file
12
app/src/main/res/menu/opt_local.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<item
|
||||
android:id="@+id/action_import"
|
||||
android:orderInCategory="50"
|
||||
android:title="@string/_import"
|
||||
app:showAsAction="never" />
|
||||
|
||||
</menu>
|
||||
9
app/src/main/res/menu/popup_local.xml
Normal file
9
app/src/main/res/menu/popup_local.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<item
|
||||
android:id="@+id/action_delete"
|
||||
android:title="@string/delete" />
|
||||
|
||||
</menu>
|
||||
@@ -65,8 +65,13 @@
|
||||
<string name="text_clear_history_prompt">Are you rally want to clear all your reading history? This action cannot be undone.</string>
|
||||
<string name="remove">Remove</string>
|
||||
<string name="_s_removed_from_history">\"%s\" removed from history</string>
|
||||
<string name="_s_deleted_from_local_storage">\"%s\" deleted from local storage</string>
|
||||
<string name="wait_for_loading_finish">Wait for the load to finish</string>
|
||||
<string name="save_page">Save page</string>
|
||||
<string name="page_saved">Page saved successful</string>
|
||||
<string name="share_image">Share image</string>
|
||||
<string name="_import">Import</string>
|
||||
<string name="delete">Delete</string>
|
||||
<string name="operation_not_supported">This operation is not supported</string>
|
||||
<string name="text_file_not_supported">Invalid file. Only ZIP and CBZ are supported.</string>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user