Import local manga

pull/1/head
Koitharu 6 years ago
parent 013a734136
commit dce877a139

@ -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)

@ -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>

@ -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>
Loading…
Cancel
Save