diff --git a/app/build.gradle b/app/build.gradle
index 43acff0f8..2d058198e 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -85,6 +85,8 @@ dependencies {
implementation 'com.davemorrissey.labs:subsampling-scale-image-view:3.10.0'
implementation 'com.tomclaw.cache:cache:1.0'
+ debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.2'
+
testImplementation 'junit:junit:4.13'
testImplementation 'androidx.test:core:1.2.0'
testImplementation 'org.mockito:mockito-core:2.23.0'
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index a7a700e73..72e881137 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -6,6 +6,7 @@
+
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,
+ 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
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/ui/common/list/BaseRecyclerAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/ui/common/list/BaseRecyclerAdapter.kt
index 3491c234e..a33641066 100644
--- a/app/src/main/java/org/koitharu/kotatsu/ui/common/list/BaseRecyclerAdapter.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/ui/common/list/BaseRecyclerAdapter.kt
@@ -14,6 +14,8 @@ abstract class BaseRecyclerAdapter(private val onItemClickListener: OnRecy
val items get() = dataSet.toImmutableList()
+ val hasItems get() = dataSet.isNotEmpty()
+
init {
@Suppress("LeakingThis")
setHasStableIds(true)
diff --git a/app/src/main/java/org/koitharu/kotatsu/ui/reader/ReaderActivity.kt b/app/src/main/java/org/koitharu/kotatsu/ui/reader/ReaderActivity.kt
index eccbb85e3..957f99cf8 100644
--- a/app/src/main/java/org/koitharu/kotatsu/ui/reader/ReaderActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/ui/reader/ReaderActivity.kt
@@ -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"
diff --git a/app/src/main/java/org/koitharu/kotatsu/ui/reader/ReaderPresenter.kt b/app/src/main/java/org/koitharu/kotatsu/ui/reader/ReaderPresenter.kt
index ff2d08785..f30b6873f 100644
--- a/app/src/main/java/org/koitharu/kotatsu/ui/reader/ReaderPresenter.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/ui/reader/ReaderPresenter.kt
@@ -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() {
@@ -20,7 +30,7 @@ class ReaderPresenter : BasePresenter() {
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() {
}
}
+ 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().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)
+ }
+ }
+ }
+ }
+
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/ui/reader/ReaderView.kt b/app/src/main/java/org/koitharu/kotatsu/ui/reader/ReaderView.kt
index 1b9e35e74..6cacba9ee 100644
--- a/app/src/main/java/org/koitharu/kotatsu/ui/reader/ReaderView.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/ui/reader/ReaderView.kt
@@ -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?)
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/MediaStoreCompat.kt b/app/src/main/java/org/koitharu/kotatsu/utils/MediaStoreCompat.kt
new file mode 100644
index 000000000..b086f039b
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/MediaStoreCompat.kt
@@ -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
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ShareHelper.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ShareHelper.kt
index 31e526ccd..0a990dca0 100644
--- a/app/src/main/java/org/koitharu/kotatsu/utils/ShareHelper.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/ShareHelper.kt
@@ -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)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/HttpExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/HttpExt.kt
new file mode 100644
index 000000000..e8a8a0766
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/HttpExt.kt
@@ -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")
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt
index e711d3816..54f6eb6eb 100644
--- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt
@@ -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 {
+ val result = HashSet(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 {
diff --git a/app/src/main/res/drawable/ic_page_image.xml b/app/src/main/res/drawable/ic_page_image.xml
new file mode 100644
index 000000000..199b30568
--- /dev/null
+++ b/app/src/main/res/drawable/ic_page_image.xml
@@ -0,0 +1,11 @@
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/menu/opt_reader_bottom.xml b/app/src/main/res/menu/opt_reader_bottom.xml
index cd018def0..4f3d6f586 100644
--- a/app/src/main/res/menu/opt_reader_bottom.xml
+++ b/app/src/main/res/menu/opt_reader_bottom.xml
@@ -10,6 +10,12 @@
android:visible="false"
app:showAsAction="ifRoom" />
+
+
- Are you rally want to clear all your reading history? This action cannot be undone.
Remove
\"%s\" removed from history
+ Wait for the load to finish
+ Save page
+ Page saved successful
+ Share image
\ No newline at end of file