Save manga page image

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

@ -85,6 +85,8 @@ dependencies {
implementation 'com.davemorrissey.labs:subsampling-scale-image-view:3.10.0' implementation 'com.davemorrissey.labs:subsampling-scale-image-view:3.10.0'
implementation 'com.tomclaw.cache:cache:1.0' implementation 'com.tomclaw.cache:cache:1.0'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.2'
testImplementation 'junit:junit:4.13' testImplementation 'junit:junit:4.13'
testImplementation 'androidx.test:core:1.2.0' testImplementation 'androidx.test:core:1.2.0'
testImplementation 'org.mockito:mockito-core:2.23.0' testImplementation 'org.mockito:mockito-core:2.23.0'

@ -6,6 +6,7 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<application <application
android:name="org.koitharu.kotatsu.KotatsuApp" android:name="org.koitharu.kotatsu.KotatsuApp"

@ -1,9 +1,12 @@
package org.koitharu.kotatsu.ui.common package org.koitharu.kotatsu.ui.common
import android.content.pm.PackageManager
import android.view.KeyEvent import android.view.KeyEvent
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import moxy.MvpAppCompatActivity import moxy.MvpAppCompatActivity
import org.koin.core.KoinComponent import org.koin.core.KoinComponent
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
@ -11,6 +14,8 @@ import org.koitharu.kotatsu.R
abstract class BaseActivity : MvpAppCompatActivity(), KoinComponent { abstract class BaseActivity : MvpAppCompatActivity(), KoinComponent {
private var permissionCallback: ((Boolean) -> Unit)? = null
override fun setContentView(layoutResID: Int) { override fun setContentView(layoutResID: Int) {
super.setContentView(layoutResID) super.setContentView(layoutResID)
setupToolbar() setupToolbar()
@ -30,6 +35,33 @@ abstract class BaseActivity : MvpAppCompatActivity(), KoinComponent {
true true
} else super.onOptionsItemSelected(item) } 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 { override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
//TODO remove. Just for testing //TODO remove. Just for testing
if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_UP) { if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_UP) {
@ -38,4 +70,9 @@ abstract class BaseActivity : MvpAppCompatActivity(), KoinComponent {
} }
return super.onKeyDown(keyCode, event) 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 items get() = dataSet.toImmutableList()
val hasItems get() = dataSet.isNotEmpty()
init { init {
@Suppress("LeakingThis") @Suppress("LeakingThis")
setHasStableIds(true) setHasStableIds(true)

@ -1,15 +1,18 @@
package org.koitharu.kotatsu.ui.reader package org.koitharu.kotatsu.ui.reader
import android.Manifest
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.view.Gravity
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.MotionEvent import android.view.MotionEvent
import android.widget.Button
import android.widget.Toast import android.widget.Toast
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.activity_reader.* import kotlinx.android.synthetic.main.activity_reader.*
import moxy.ktx.moxyPresenter import moxy.ktx.moxyPresenter
import org.koitharu.kotatsu.R 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.OnPageSelectListener
import org.koitharu.kotatsu.ui.reader.thumbnails.PagesThumbnailsSheet import org.koitharu.kotatsu.ui.reader.thumbnails.PagesThumbnailsSheet
import org.koitharu.kotatsu.utils.GridTouchHelper import org.koitharu.kotatsu.utils.GridTouchHelper
import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.anim.Motion import org.koitharu.kotatsu.utils.anim.Motion
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.*
@ -94,10 +98,26 @@ class ReaderActivity : BaseFullscreenActivity(), ReaderView, ChaptersDialog.OnCh
true true
} }
R.id.action_pages_thumbs -> { R.id.action_pages_thumbs -> {
if (adapter.hasItems) {
PagesThumbnailsSheet.show( PagesThumbnailsSheet.show(
supportFragmentManager, adapter.items, supportFragmentManager, adapter.items,
state.chapter?.name ?: title?.toString().orEmpty() 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 true
} }
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
@ -117,6 +137,11 @@ class ReaderActivity : BaseFullscreenActivity(), ReaderView, ChaptersDialog.OnCh
setTitle(R.string.error_occurred) setTitle(R.string.error_occurred)
setMessage(e.message) setMessage(e.message)
setPositiveButton(R.string.close, null) setPositiveButton(R.string.close, null)
if (!adapter.hasItems) {
setOnDismissListener {
finish()
}
}
} }
} }
@ -152,8 +177,8 @@ class ReaderActivity : BaseFullscreenActivity(), ReaderView, ChaptersDialog.OnCh
) { ) {
false false
} else { } else {
val target = rootLayout.hitTest(rawX, rawY) val targets = rootLayout.hitTest(rawX, rawY)
target !is Button 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 { companion object {
private const val EXTRA_STATE = "state" private const val EXTRA_STATE = "state"

@ -1,13 +1,23 @@
package org.koitharu.kotatsu.ui.reader 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.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import moxy.InjectViewState import moxy.InjectViewState
import okhttp3.OkHttpClient
import okhttp3.Request
import org.koitharu.kotatsu.BuildConfig 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.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.utils.MediaStoreCompat
import org.koitharu.kotatsu.utils.ext.await
import org.koitharu.kotatsu.utils.ext.contentDisposition
import org.koitharu.kotatsu.utils.ext.mimeType
@InjectViewState @InjectViewState
class ReaderPresenter : BasePresenter<ReaderView>() { class ReaderPresenter : BasePresenter<ReaderView>() {
@ -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 package org.koitharu.kotatsu.ui.reader
import android.net.Uri
import moxy.MvpView import moxy.MvpView
import moxy.viewstate.strategy.AddToEndSingleStrategy import moxy.viewstate.strategy.AddToEndSingleStrategy
import moxy.viewstate.strategy.OneExecutionStateStrategy import moxy.viewstate.strategy.OneExecutionStateStrategy
@ -16,4 +17,7 @@ interface ReaderView : MvpView {
@StateStrategyType(OneExecutionStateStrategy::class) @StateStrategyType(OneExecutionStateStrategy::class)
fun onError(e: Exception) 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.Context
import android.content.Intent import android.content.Intent
import android.net.Uri
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@ -19,7 +20,8 @@ object ShareHelper {
append("\n \n") append("\n \n")
append(manga.url) 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) context.startActivity(shareIntent)
} }
@ -29,7 +31,17 @@ object ShareHelper {
val intent = Intent(Intent.ACTION_SEND) val intent = Intent(Intent.ACTION_SEND)
intent.setDataAndType(uri, context.contentResolver.getType(uri)) intent.setDataAndType(uri, context.contentResolver.getType(uri))
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) 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) 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.appcompat.widget.PopupMenu
import androidx.core.view.children import androidx.core.view.children
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.view.postDelayed import androidx.core.view.postDelayed
import androidx.drawerlayout.widget.DrawerLayout import androidx.drawerlayout.widget.DrawerLayout
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
@ -109,20 +110,21 @@ fun View.showPopupMenu(@MenuRes menuRes: Int, onPrepare:((Menu) -> Unit)? = null
menu.show() 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() val rect = Rect()
for (child in children) { for (child in children) {
if (child.getGlobalVisibleRect(rect)) { if (child.isVisible && child.getGlobalVisibleRect(rect)) {
if (rect.contains(x, y)) { if (rect.contains(x, y)) {
return if (child is ViewGroup) { if (child is ViewGroup) {
child.hitTest(x, y) result += child.hitTest(x, y)
} else { } else {
child result += child
} }
} }
} }
} }
return null return result
} }
fun View.hasGlobalPoint(x: Int, y: Int): Boolean { fun View.hasGlobalPoint(x: Int, y: Int): Boolean {

@ -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" android:visible="false"
app:showAsAction="ifRoom" /> 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 <item
android:id="@+id/action_pages_thumbs" android:id="@+id/action_pages_thumbs"
android:icon="@drawable/ic_grid" 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="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="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> </resources>
Loading…
Cancel
Save