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