Save manga page image

This commit is contained in:
Koitharu
2020-02-23 16:25:30 +02:00
parent ac935eb203
commit 013a734136
14 changed files with 241 additions and 17 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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