Save manga page image
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
|
||||
<application
|
||||
android:name="org.koitharu.kotatsu.KotatsuApp"
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package org.koitharu.kotatsu.ui.common
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
import android.view.KeyEvent
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import moxy.MvpAppCompatActivity
|
||||
import org.koin.core.KoinComponent
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
@@ -11,6 +14,8 @@ import org.koitharu.kotatsu.R
|
||||
|
||||
abstract class BaseActivity : MvpAppCompatActivity(), KoinComponent {
|
||||
|
||||
private var permissionCallback: ((Boolean) -> Unit)? = null
|
||||
|
||||
override fun setContentView(layoutResID: Int) {
|
||||
super.setContentView(layoutResID)
|
||||
setupToolbar()
|
||||
@@ -30,6 +35,33 @@ abstract class BaseActivity : MvpAppCompatActivity(), KoinComponent {
|
||||
true
|
||||
} else super.onOptionsItemSelected(item)
|
||||
|
||||
fun requestPermission(permission: String, callback: (Boolean) -> Unit) {
|
||||
if (ContextCompat.checkSelfPermission(
|
||||
this,
|
||||
permission
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
callback(true)
|
||||
} else {
|
||||
permissionCallback = callback
|
||||
ActivityCompat.requestPermissions(this, arrayOf(permission), REQUEST_PERMISSION)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<out String>,
|
||||
grantResults: IntArray
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
if (requestCode == REQUEST_PERMISSION) {
|
||||
grantResults.singleOrNull()?.let {
|
||||
permissionCallback?.invoke(it == PackageManager.PERMISSION_GRANTED)
|
||||
}
|
||||
permissionCallback = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
|
||||
//TODO remove. Just for testing
|
||||
if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_UP) {
|
||||
@@ -38,4 +70,9 @@ abstract class BaseActivity : MvpAppCompatActivity(), KoinComponent {
|
||||
}
|
||||
return super.onKeyDown(keyCode, event)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
const val REQUEST_PERMISSION = 30
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ abstract class BaseRecyclerAdapter<T, E>(private val onItemClickListener: OnRecy
|
||||
|
||||
val items get() = dataSet.toImmutableList()
|
||||
|
||||
val hasItems get() = dataSet.isNotEmpty()
|
||||
|
||||
init {
|
||||
@Suppress("LeakingThis")
|
||||
setHasStableIds(true)
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
package org.koitharu.kotatsu.ui.reader
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.MotionEvent
|
||||
import android.widget.Button
|
||||
import android.widget.Toast
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.android.synthetic.main.activity_reader.*
|
||||
import moxy.ktx.moxyPresenter
|
||||
import org.koitharu.kotatsu.R
|
||||
@@ -21,6 +24,7 @@ import org.koitharu.kotatsu.ui.common.BaseFullscreenActivity
|
||||
import org.koitharu.kotatsu.ui.reader.thumbnails.OnPageSelectListener
|
||||
import org.koitharu.kotatsu.ui.reader.thumbnails.PagesThumbnailsSheet
|
||||
import org.koitharu.kotatsu.utils.GridTouchHelper
|
||||
import org.koitharu.kotatsu.utils.ShareHelper
|
||||
import org.koitharu.kotatsu.utils.anim.Motion
|
||||
import org.koitharu.kotatsu.utils.ext.*
|
||||
|
||||
@@ -94,10 +98,26 @@ class ReaderActivity : BaseFullscreenActivity(), ReaderView, ChaptersDialog.OnCh
|
||||
true
|
||||
}
|
||||
R.id.action_pages_thumbs -> {
|
||||
PagesThumbnailsSheet.show(
|
||||
supportFragmentManager, adapter.items,
|
||||
state.chapter?.name ?: title?.toString().orEmpty()
|
||||
)
|
||||
if (adapter.hasItems) {
|
||||
PagesThumbnailsSheet.show(
|
||||
supportFragmentManager, adapter.items,
|
||||
state.chapter?.name ?: title?.toString().orEmpty()
|
||||
)
|
||||
} else {
|
||||
showWaitWhileLoading()
|
||||
}
|
||||
true
|
||||
}
|
||||
R.id.action_save_page -> {
|
||||
if (adapter.hasItems) {
|
||||
requestPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) {
|
||||
if (it) {
|
||||
presenter.savePage(contentResolver, adapter.getItem(pager.currentItem))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
showWaitWhileLoading()
|
||||
}
|
||||
true
|
||||
}
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
@@ -117,6 +137,11 @@ class ReaderActivity : BaseFullscreenActivity(), ReaderView, ChaptersDialog.OnCh
|
||||
setTitle(R.string.error_occurred)
|
||||
setMessage(e.message)
|
||||
setPositiveButton(R.string.close, null)
|
||||
if (!adapter.hasItems) {
|
||||
setOnDismissListener {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,8 +177,8 @@ class ReaderActivity : BaseFullscreenActivity(), ReaderView, ChaptersDialog.OnCh
|
||||
) {
|
||||
false
|
||||
} else {
|
||||
val target = rootLayout.hitTest(rawX, rawY)
|
||||
target !is Button
|
||||
val targets = rootLayout.hitTest(rawX, rawY)
|
||||
targets.none { it.hasOnClickListeners() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +203,23 @@ class ReaderActivity : BaseFullscreenActivity(), ReaderView, ChaptersDialog.OnCh
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPageSaved(uri: Uri?) {
|
||||
if (uri != null) {
|
||||
Snackbar.make(pager, R.string.page_saved, Snackbar.LENGTH_LONG)
|
||||
.setAction(R.string.share) {
|
||||
ShareHelper.shareImage(this, uri)
|
||||
}.show()
|
||||
} else {
|
||||
Snackbar.make(pager, R.string.error_occurred, Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showWaitWhileLoading() {
|
||||
Toast.makeText(this, R.string.wait_for_loading_finish, Toast.LENGTH_SHORT).apply {
|
||||
setGravity(Gravity.CENTER, 0, 0)
|
||||
}.show()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val EXTRA_STATE = "state"
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
package org.koitharu.kotatsu.ui.reader
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.webkit.URLUtil
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import moxy.InjectViewState
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.domain.history.HistoryRepository
|
||||
import org.koitharu.kotatsu.core.model.MangaPage
|
||||
import org.koitharu.kotatsu.domain.MangaProviderFactory
|
||||
import org.koitharu.kotatsu.domain.history.HistoryRepository
|
||||
import org.koitharu.kotatsu.ui.common.BasePresenter
|
||||
import org.koitharu.kotatsu.utils.MediaStoreCompat
|
||||
import org.koitharu.kotatsu.utils.ext.await
|
||||
import org.koitharu.kotatsu.utils.ext.contentDisposition
|
||||
import org.koitharu.kotatsu.utils.ext.mimeType
|
||||
|
||||
@InjectViewState
|
||||
class ReaderPresenter : BasePresenter<ReaderView>() {
|
||||
@@ -20,7 +30,7 @@ class ReaderPresenter : BasePresenter<ReaderView>() {
|
||||
val repo = MangaProviderFactory.create(state.manga.source)
|
||||
val chapter = state.chapter ?: repo.getDetails(state.manga).chapters
|
||||
?.first { it.id == state.chapterId }
|
||||
?: throw RuntimeException("Chapter ${state.chapterId} not found")
|
||||
?: throw RuntimeException("Chapter ${state.chapterId} not found")
|
||||
repo.getPages(chapter)
|
||||
}
|
||||
viewState.onPagesReady(pages, state.page)
|
||||
@@ -45,4 +55,32 @@ class ReaderPresenter : BasePresenter<ReaderView>() {
|
||||
}
|
||||
}
|
||||
|
||||
fun savePage(resolver: ContentResolver, page: MangaPage) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val repo = MangaProviderFactory.create(page.source)
|
||||
val url = repo.getPageFullUrl(page)
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.build()
|
||||
val uri = getKoin().get<OkHttpClient>().newCall(request).await().use { response ->
|
||||
val fileName =
|
||||
URLUtil.guessFileName(url, response.contentDisposition, response.mimeType)
|
||||
MediaStoreCompat.insertImage(resolver, fileName) {
|
||||
response.body!!.byteStream().copyTo(it)
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
viewState.onPageSaved(uri)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
viewState.onPageSaved(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.ui.reader
|
||||
|
||||
import android.net.Uri
|
||||
import moxy.MvpView
|
||||
import moxy.viewstate.strategy.AddToEndSingleStrategy
|
||||
import moxy.viewstate.strategy.OneExecutionStateStrategy
|
||||
@@ -16,4 +17,7 @@ interface ReaderView : MvpView {
|
||||
|
||||
@StateStrategyType(OneExecutionStateStrategy::class)
|
||||
fun onError(e: Exception)
|
||||
|
||||
@StateStrategyType(OneExecutionStateStrategy::class)
|
||||
fun onPageSaved(uri: Uri?)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package org.koitharu.kotatsu.utils
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentValues
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.webkit.MimeTypeMap
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import java.io.OutputStream
|
||||
|
||||
object MediaStoreCompat {
|
||||
|
||||
@JvmStatic
|
||||
fun insertImage(
|
||||
resolver: ContentResolver,
|
||||
fileName: String,
|
||||
block: (OutputStream) -> Unit
|
||||
): Uri? {
|
||||
val name = fileName.substringBeforeLast('.')
|
||||
val cv = ContentValues(7)
|
||||
cv.put(MediaStore.Images.Media.DISPLAY_NAME, name)
|
||||
cv.put(MediaStore.Images.Media.TITLE, name)
|
||||
cv.put(
|
||||
MediaStore.Images.Media.MIME_TYPE,
|
||||
MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileName.substringAfterLast('.'))
|
||||
)
|
||||
cv.put(MediaStore.Images.Media.DATE_ADDED, System.currentTimeMillis())
|
||||
cv.put(MediaStore.Images.Media.DATE_MODIFIED, System.currentTimeMillis())
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
cv.put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis())
|
||||
cv.put(MediaStore.Images.Media.IS_PENDING, 1)
|
||||
}
|
||||
var uri: Uri? = null
|
||||
try {
|
||||
uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, cv)
|
||||
resolver.openOutputStream(uri!!)?.use(block)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
cv.clear()
|
||||
cv.put(MediaStore.Images.Media.IS_PENDING, 0)
|
||||
resolver.update(uri, cv, null, null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
uri?.let {
|
||||
resolver.delete(it, null, null)
|
||||
}
|
||||
uri = null
|
||||
}
|
||||
return uri
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.core.content.FileProvider
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
@@ -19,7 +20,8 @@ object ShareHelper {
|
||||
append("\n \n")
|
||||
append(manga.url)
|
||||
})
|
||||
val shareIntent = Intent.createChooser(intent, context.getString(R.string.share_s, manga.title))
|
||||
val shareIntent =
|
||||
Intent.createChooser(intent, context.getString(R.string.share_s, manga.title))
|
||||
context.startActivity(shareIntent)
|
||||
}
|
||||
|
||||
@@ -29,7 +31,17 @@ object ShareHelper {
|
||||
val intent = Intent(Intent.ACTION_SEND)
|
||||
intent.setDataAndType(uri, context.contentResolver.getType(uri))
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
val shareIntent = Intent.createChooser(intent, context.getString(R.string.share_s, file.name))
|
||||
val shareIntent =
|
||||
Intent.createChooser(intent, context.getString(R.string.share_s, file.name))
|
||||
context.startActivity(shareIntent)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun shareImage(context: Context, uri: Uri) {
|
||||
val intent = Intent(Intent.ACTION_SEND)
|
||||
intent.setDataAndType(uri, context.contentResolver.getType(uri))
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
val shareIntent = Intent.createChooser(intent, context.getString(R.string.share_image))
|
||||
context.startActivity(shareIntent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.koitharu.kotatsu.utils.ext
|
||||
|
||||
import okhttp3.Response
|
||||
|
||||
val Response.mimeType: String?
|
||||
get() = body?.contentType()?.run { "$type/$subtype" }
|
||||
|
||||
val Response.contentDisposition: String?
|
||||
get() = header("Content-Disposition")
|
||||
@@ -12,6 +12,7 @@ import androidx.annotation.MenuRes
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.core.view.children
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.postDelayed
|
||||
import androidx.drawerlayout.widget.DrawerLayout
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
@@ -109,20 +110,21 @@ fun View.showPopupMenu(@MenuRes menuRes: Int, onPrepare:((Menu) -> Unit)? = null
|
||||
menu.show()
|
||||
}
|
||||
|
||||
fun ViewGroup.hitTest(x: Int, y: Int): View? {
|
||||
fun ViewGroup.hitTest(x: Int, y: Int): Set<View> {
|
||||
val result = HashSet<View>(4)
|
||||
val rect = Rect()
|
||||
for (child in children) {
|
||||
if (child.getGlobalVisibleRect(rect)) {
|
||||
if (child.isVisible && child.getGlobalVisibleRect(rect)) {
|
||||
if (rect.contains(x, y)) {
|
||||
return if (child is ViewGroup) {
|
||||
child.hitTest(x, y)
|
||||
if (child is ViewGroup) {
|
||||
result += child.hitTest(x, y)
|
||||
} else {
|
||||
child
|
||||
result += child
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
return result
|
||||
}
|
||||
|
||||
fun View.hasGlobalPoint(x: Int, y: Int): Boolean {
|
||||
|
||||
11
app/src/main/res/drawable/ic_page_image.xml
Normal file
11
app/src/main/res/drawable/ic_page_image.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#000"
|
||||
android:pathData="M14,2L20,8V20A2,2 0 0,1 18,22H6A2,2 0 0,1 4,20V4A2,2 0 0,1 6,2H14M18,20V9H13V4H6V20H18M17,13V19H7L12,14L14,16M10,10.5A1.5,1.5 0 0,1 8.5,12A1.5,1.5 0 0,1 7,10.5A1.5,1.5 0 0,1 8.5,9A1.5,1.5 0 0,1 10,10.5Z" />
|
||||
</vector>
|
||||
@@ -10,6 +10,12 @@
|
||||
android:visible="false"
|
||||
app:showAsAction="ifRoom" />
|
||||
|
||||
<item
|
||||
android:id="@+id/action_save_page"
|
||||
android:icon="@drawable/ic_page_image"
|
||||
android:title="@string/save_page"
|
||||
app:showAsAction="ifRoom" />
|
||||
|
||||
<item
|
||||
android:id="@+id/action_pages_thumbs"
|
||||
android:icon="@drawable/ic_grid"
|
||||
|
||||
@@ -65,4 +65,8 @@
|
||||
<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="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>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user