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.readText
|
||||||
import org.koitharu.kotatsu.utils.ext.safe
|
import org.koitharu.kotatsu.utils.ext.safe
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.util.*
|
||||||
import java.util.zip.ZipFile
|
import java.util.zip.ZipFile
|
||||||
|
|
||||||
class LocalMangaRepository(loaderContext: MangaLoaderContext) : BaseMangaRepository(loaderContext) {
|
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) =
|
private fun zipUri(file: File, entryName: String) =
|
||||||
Uri.fromParts("cbz", file.path, entryName).toString()
|
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(
|
Snackbar.make(
|
||||||
recyclerView, getString(
|
recyclerView, getString(
|
||||||
R.string._s_removed_from_history,
|
R.string._s_removed_from_history,
|
||||||
item.title.ellipsize(14)
|
item.title.ellipsize(16)
|
||||||
), Snackbar.LENGTH_SHORT
|
), Snackbar.LENGTH_SHORT
|
||||||
).show()
|
).show()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
package org.koitharu.kotatsu.ui.main.list.local
|
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 kotlinx.android.synthetic.main.fragment_list.*
|
||||||
import moxy.ktx.moxyPresenter
|
import moxy.ktx.moxyPresenter
|
||||||
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.model.Manga
|
||||||
import org.koitharu.kotatsu.ui.main.list.MangaListFragment
|
import org.koitharu.kotatsu.ui.main.list.MangaListFragment
|
||||||
|
import org.koitharu.kotatsu.utils.ext.ellipsize
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
class LocalListFragment : MangaListFragment<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? {
|
override fun getTitle(): CharSequence? {
|
||||||
return getString(R.string.local_storage)
|
return getString(R.string.local_storage)
|
||||||
}
|
}
|
||||||
@@ -25,8 +62,45 @@ class LocalListFragment : MangaListFragment<File>() {
|
|||||||
textView_holder.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0)
|
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 {
|
companion object {
|
||||||
|
|
||||||
|
private const val REQUEST_IMPORT = 50
|
||||||
|
|
||||||
fun newInstance() = LocalListFragment()
|
fun newInstance() = LocalListFragment()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,26 @@
|
|||||||
package org.koitharu.kotatsu.ui.main.list.local
|
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.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import moxy.InjectViewState
|
import moxy.InjectViewState
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
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.model.MangaSource
|
||||||
import org.koitharu.kotatsu.core.parser.LocalMangaRepository
|
import org.koitharu.kotatsu.core.parser.LocalMangaRepository
|
||||||
import org.koitharu.kotatsu.domain.MangaProviderFactory
|
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.common.BasePresenter
|
||||||
import org.koitharu.kotatsu.ui.main.list.MangaListView
|
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.File
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
@InjectViewState
|
@InjectViewState
|
||||||
class LocalListPresenter : BasePresenter<MangaListView<File>>() {
|
class LocalListPresenter : BasePresenter<MangaListView<File>>() {
|
||||||
@@ -30,6 +40,7 @@ class LocalListPresenter : BasePresenter<MangaListView<File>>() {
|
|||||||
repository.getList(0)
|
repository.getList(0)
|
||||||
}
|
}
|
||||||
viewState.onListChanged(list)
|
viewState.onListChanged(list)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
e.printStackTrace()
|
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.ContentResolver
|
||||||
import android.content.ContentValues
|
import android.content.ContentValues
|
||||||
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
|
import android.provider.OpenableColumns
|
||||||
import android.webkit.MimeTypeMap
|
import android.webkit.MimeTypeMap
|
||||||
|
import androidx.core.database.getStringOrNull
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
|
|
||||||
@@ -51,4 +54,17 @@ object MediaStoreCompat {
|
|||||||
}
|
}
|
||||||
return uri
|
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 kotlinx.coroutines.delay
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
|
||||||
inline fun <T, R> T.safe(action: T.() -> R?) = try {
|
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) {
|
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)
|
is IOException -> resources.getString(R.string.network_error)
|
||||||
else -> if (BuildConfig.DEBUG) {
|
else -> if (BuildConfig.DEBUG) {
|
||||||
message ?: resources.getString(R.string.error_occurred)
|
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="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="remove">Remove</string>
|
||||||
<string name="_s_removed_from_history">\"%s\" removed from history</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="wait_for_loading_finish">Wait for the load to finish</string>
|
||||||
<string name="save_page">Save page</string>
|
<string name="save_page">Save page</string>
|
||||||
<string name="page_saved">Page saved successful</string>
|
<string name="page_saved">Page saved successful</string>
|
||||||
<string name="share_image">Share image</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>
|
</resources>
|
||||||
Reference in New Issue
Block a user