diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ca07c95dfa..c9aaa32fd1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -86,9 +86,9 @@ - - + + + { + shareImage(context, intent.getStringExtra(EXTRA_FILE_LOCATION)) + context.notificationManager.cancel(intent.getIntExtra(NOTIFICATION_ID, 5)) + } + ACTION_SHOW_IMAGE -> + showImage(context, intent.getStringExtra(EXTRA_FILE_LOCATION)) + ACTION_DELETE_IMAGE -> { + deleteImage(intent.getStringExtra(EXTRA_FILE_LOCATION)) + context.notificationManager.cancel(intent.getIntExtra(NOTIFICATION_ID, 5)) + } + } + } + + fun deleteImage(path: String) { + val file = File(path) + if (file.exists()) file.delete() + } + + fun shareImage(context: Context, path: String) { + val shareIntent = Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_STREAM, Uri.parse(path)) + flags = Intent.FLAG_ACTIVITY_NEW_TASK + type = "image/jpeg" + } + context.startActivity(Intent.createChooser(shareIntent, context.resources.getText(R.string.action_share)) + .apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK }) + } + + fun showImage(context: Context, path: String) { + val intent = Intent().apply { + action = Intent.ACTION_VIEW + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK + setDataAndType(Uri.parse("file://" + path), "image/*") + } + context.startActivity(intent) + } + + companion object { + const val ACTION_SHARE_IMAGE = "eu.kanade.SHARE_IMAGE" + + const val ACTION_SHOW_IMAGE = "eu.kanade.SHOW_IMAGE" + + const val ACTION_DELETE_IMAGE = "eu.kanade.DELETE_IMAGE" + + const val EXTRA_FILE_LOCATION = "file_location" + + const val NOTIFICATION_ID = "notification_id" + + fun shareImageIntent(context: Context, path: String, notificationId: Int): PendingIntent { + val intent = Intent(context, ImageNotificationReceiver::class.java).apply { + action = ACTION_SHARE_IMAGE + putExtra(EXTRA_FILE_LOCATION, path) + putExtra(NOTIFICATION_ID, notificationId) + } + return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + fun showImageIntent(context: Context, path: String): PendingIntent { + val intent = Intent(context, ImageNotificationReceiver::class.java).apply { + action = ACTION_SHOW_IMAGE + putExtra(EXTRA_FILE_LOCATION, path) + } + return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + fun deleteImageIntent(context: Context, path: String, notificationId: Int): PendingIntent { + val intent = Intent(context, ImageNotificationReceiver::class.java).apply { + action = ACTION_DELETE_IMAGE + putExtra(EXTRA_FILE_LOCATION, path) + putExtra(NOTIFICATION_ID, notificationId) + } + return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/ImageNotifier.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/ImageNotifier.kt new file mode 100644 index 0000000000..dbd4a5cecf --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/ImageNotifier.kt @@ -0,0 +1,124 @@ +package eu.kanade.tachiyomi.data.download + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.support.v4.app.NotificationCompat +import eu.kanade.tachiyomi.Constants +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.util.notificationManager +import java.io.File + + +class ImageNotifier(private val context: Context) { + /** + * Notification builder. + */ + private val notificationBuilder = NotificationCompat.Builder(context) + + /** + * Id of the notification. + */ + private val notificationId: Int + get() = Constants.NOTIFICATION_DOWNLOAD_IMAGE_ID + + /** + * Status of download. Used for correct notification icon. + */ + private var isDownloading = false + + /** + * Called when download progress changes. + * @param progress progress value in range [0,100] + */ + fun onProgressChange(progress: Int) { + with(notificationBuilder) { + if (!isDownloading) { + setContentTitle(context.getString(R.string.saving_picture)) + setSmallIcon(android.R.drawable.stat_sys_download) + setLargeIcon(null) + setStyle(null) + // Clear old actions if they exist + if (!mActions.isEmpty()) + mActions.clear() + isDownloading = true + } + + setProgress(100, progress, false) + } + // Displays the progress bar on notification + context.notificationManager.notify(notificationId, notificationBuilder.build()) + } + + /** + * Called when image download is complete + * @param bitmap image file containing downloaded page image + */ + fun onComplete(bitmap: Bitmap, file: File) { + with(notificationBuilder) { + if (isDownloading) { + setProgress(0, 0, false) + isDownloading = false + } + setContentTitle(context.getString(R.string.picture_saved)) + setSmallIcon(R.drawable.ic_insert_photo_black_24dp) + setLargeIcon(bitmap) + setStyle(NotificationCompat.BigPictureStyle().bigPicture(bitmap)) + setAutoCancel(true) + + // Clear old actions if they exist + if (!mActions.isEmpty()) + mActions.clear() + + setContentIntent(ImageNotificationReceiver.showImageIntent(context, file.absolutePath)) + // Share action + addAction(R.drawable.ic_share_white_24dp, + context.getString(R.string.action_share), + ImageNotificationReceiver.shareImageIntent(context, file.absolutePath, notificationId)) + // Delete action + addAction(R.drawable.ic_delete_white_24dp, + context.getString(R.string.action_delete), + ImageNotificationReceiver.deleteImageIntent(context, file.absolutePath, notificationId)) + } + // Displays the progress bar on notification + context.notificationManager.notify(notificationId, notificationBuilder.build()) + } + + fun onComplete(file: File) { + onComplete(convertToBitmap(file), file) + } + + /** + * Clears the notification message + */ + internal fun onClear() { + context.notificationManager.cancel(notificationId) + } + + + /** + * Called on error while downloading image + * @param error string containing error information + */ + internal fun onError(error: String?) { + // Create notification + with(notificationBuilder) { + setContentTitle(context.getString(R.string.download_notifier_title_error)) + setContentText(error ?: context.getString(R.string.download_notifier_unkown_error)) + setSmallIcon(android.R.drawable.ic_menu_report_image) + setProgress(0, 0, false) + } + context.notificationManager.notify(notificationId, notificationBuilder.build()) + isDownloading = false + } + + /** + * Converts file to bitmap + */ + fun convertToBitmap(image: File): Bitmap { + val options = BitmapFactory.Options() + options.inPreferredConfig = Bitmap.Config.ARGB_8888 + return BitmapFactory.decodeFile(image.absolutePath, options) + } + +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoFragment.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoFragment.kt index 2f67fd5fb9..9cf1221982 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoFragment.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoFragment.kt @@ -184,10 +184,9 @@ class MangaInfoFragment : BaseRxFragment() { val url = source.mangaDetailsRequest(presenter.manga).url().toString() val sharingIntent = Intent(Intent.ACTION_SEND).apply { type = "text/plain" - putExtra(android.content.Intent.EXTRA_SUBJECT, presenter.manga.title) putExtra(android.content.Intent.EXTRA_TEXT, resources.getString(R.string.share_text, presenter.manga.title, url)) } - startActivity(Intent.createChooser(sharingIntent, resources.getText(R.string.share_subject))) + startActivity(Intent.createChooser(sharingIntent, resources.getText(R.string.action_share))) } catch (e: Exception) { context.toast(e.message) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt index 0b935c8735..4c1c64a780 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt @@ -145,6 +145,8 @@ class ReaderActivity : BaseRxActivity() { when (item.itemId) { R.id.action_settings -> ReaderSettingsDialog().show(supportFragmentManager, "settings") R.id.action_custom_filter -> ReaderCustomFilterDialog().show(supportFragmentManager, "filter") + R.id.action_save_page -> presenter.savePage() + R.id.action_set_as_cover -> presenter.setCover() else -> return super.onOptionsItemSelected(item) } return true @@ -393,16 +395,16 @@ class ReaderActivity : BaseRxActivity() { private fun setRotation(rotation: Int) { when (rotation) { - // Rotation free + // Rotation free 1 -> requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED - // Lock in current rotation + // Lock in current rotation 2 -> { val currentOrientation = resources.configuration.orientation setRotation(if (currentOrientation == Configuration.ORIENTATION_PORTRAIT) 3 else 4) } - // Lock in portrait + // Lock in portrait 3 -> requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT - // Lock in landscape + // Lock in landscape 4 -> requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt index 69cd6dff15..448e1fc0c5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt @@ -1,15 +1,23 @@ package eu.kanade.tachiyomi.ui.reader import android.os.Bundle +import android.os.Environment +import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.cache.ChapterCache +import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.History import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.MangaSync import eu.kanade.tachiyomi.data.download.DownloadManager +import eu.kanade.tachiyomi.data.download.ImageNotifier import eu.kanade.tachiyomi.data.mangasync.MangaSyncManager import eu.kanade.tachiyomi.data.mangasync.UpdateMangaSyncService +import eu.kanade.tachiyomi.data.network.GET +import eu.kanade.tachiyomi.data.network.NetworkHelper +import eu.kanade.tachiyomi.data.network.ProgressListener +import eu.kanade.tachiyomi.data.network.newCallWithProgress import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.source.SourceManager import eu.kanade.tachiyomi.data.source.model.Page @@ -17,6 +25,8 @@ import eu.kanade.tachiyomi.data.source.online.OnlineSource import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter import eu.kanade.tachiyomi.util.RetryWithDelay import eu.kanade.tachiyomi.util.SharedData +import eu.kanade.tachiyomi.util.saveTo +import eu.kanade.tachiyomi.util.toast import rx.Observable import rx.Subscription import rx.android.schedulers.AndroidSchedulers @@ -24,6 +34,8 @@ import rx.schedulers.Schedulers import timber.log.Timber import uy.kohesive.injekt.injectLazy import java.io.File +import java.io.IOException +import java.io.InputStream import java.util.* /** @@ -31,6 +43,11 @@ import java.util.* */ class ReaderPresenter : BasePresenter() { + /** + * Network helper + */ + private val network: NetworkHelper by injectLazy() + /** * Preferences. */ @@ -61,6 +78,11 @@ class ReaderPresenter : BasePresenter() { */ val chapterCache: ChapterCache by injectLazy() + /** + * Cover cache. + */ + val coverCache: CoverCache by injectLazy() + /** * Manga being read. */ @@ -88,6 +110,20 @@ class ReaderPresenter : BasePresenter() { */ private val source by lazy { sourceManager.get(manga.source)!! } + /** + * + */ + val imageNotifier by lazy { ImageNotifier(context) } + + /** + * Directory of pictures + */ + private val pictureDirectory: String by lazy { + Environment.getExternalStorageDirectory().absolutePath + File.separator + + Environment.DIRECTORY_PICTURES + File.separator + + context.getString(R.string.app_name) + File.separator + } + /** * Chapter list for the active manga. It's retrieved lazily and should be accessed for the first * time in a background thread to avoid blocking the UI. @@ -365,7 +401,9 @@ class ReaderPresenter : BasePresenter() { val removeAfterReadSlots = prefs.removeAfterReadSlots() when (removeAfterReadSlots) { // Setting disabled - -1 -> { /**Empty function**/ } + -1 -> { + /**Empty function**/ + } // Remove current read chapter 0 -> deleteChapter(chapter, manga) // Remove previous chapter specified by user in settings. @@ -384,8 +422,8 @@ class ReaderPresenter : BasePresenter() { Timber.e(error) } } - .subscribeOn(Schedulers.io()) - .subscribe() + .subscribeOn(Schedulers.io()) + .subscribe() } /** @@ -508,4 +546,87 @@ class ReaderPresenter : BasePresenter() { db.insertManga(manga).executeAsBlocking() } + /** + * Update cover with page file. + */ + internal fun setCover() { + chapter.pages?.get(chapter.last_page_read)?.let { + // Update cover to selected file, show error if something went wrong + try { + if (editCoverWithStream(File(it.imagePath).inputStream(), manga)) { + context.toast(R.string.cover_updated) + } else { + throw Exception("Stream copy failed") + } + } catch(e: Exception) { + context.toast(R.string.notification_manga_update_failed) + Timber.e(e.message) + } + } + } + + /** + * Called to copy image to cache + * @param inputStream the new cover. + * @param manga the manga edited. + * @return true if the cover is updated, false otherwise + */ + @Throws(IOException::class) + private fun editCoverWithStream(inputStream: InputStream, manga: Manga): Boolean { + if (manga.thumbnail_url != null && manga.favorite) { + coverCache.copyToCache(manga.thumbnail_url!!, inputStream) + return true + } + return false + } + + /** + * Save page to local storage + * @throws IOException + */ + @Throws(IOException::class) + internal fun savePage() { + chapter.pages?.get(chapter.last_page_read)?.let { page -> + // File where the image will be saved + val destFile = File(pictureDirectory, manga.title + " - " + chapter.name + + " - " + downloadManager.getImageFilename(page)) + + if (destFile.exists()) { + imageNotifier.onComplete(destFile) + } else { + // Progress of the download + var savedProgress = 0 + + val progressListener = object : ProgressListener { + override fun update(bytesRead: Long, contentLength: Long, done: Boolean) { + val progress = (100 * bytesRead / contentLength).toInt() + if (progress > savedProgress) { + savedProgress = progress + imageNotifier.onProgressChange(progress) + } + } + } + + // Download and save the image. + Observable.fromCallable { -> + network.client.newCallWithProgress(GET(page.imageUrl!!), progressListener).execute() + }.map { + response -> + if (response.isSuccessful) { + response.body().source().saveTo(destFile) + imageNotifier.onComplete(destFile) + } else { + response.close() + throw Exception("Unsuccessful response") + } + } + .observeOn(AndroidSchedulers.mainThread()) + .subscribeOn(Schedulers.io()) + .subscribe({}, { error -> + Timber.e(error.message) + imageNotifier.onError(error.message) + }) + } + } + } } diff --git a/app/src/main/res/drawable/ic_insert_photo_black_24dp.xml b/app/src/main/res/drawable/ic_insert_photo_black_24dp.xml new file mode 100644 index 0000000000..7c62bb3073 --- /dev/null +++ b/app/src/main/res/drawable/ic_insert_photo_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 91b4af0914..642b4c9028 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -223,7 +223,6 @@ Status Source Genres - Share… Check out %1$s! at %2$s Circular icon Rounded icon @@ -267,10 +266,18 @@ Status Chapters + + Custom filter + Download page + Set as cover + Cover updated This will remove the read date of this chapter. Are you sure? Reset all chapters for this manga + + Picture saved + Saving picture Custom filter