/* * Copyright 2018 Allan Wang * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package com.pitchedapps.frost.activities import android.content.Context import android.content.Intent import android.content.res.ColorStateList import android.graphics.Color import android.os.Bundle import android.os.Environment import android.view.View import ca.allanwang.kau.internal.KauBaseActivity import ca.allanwang.kau.logging.KauLoggerExtension import ca.allanwang.kau.mediapicker.scanMedia import ca.allanwang.kau.permissions.PERMISSION_WRITE_EXTERNAL_STORAGE import ca.allanwang.kau.permissions.kauRequestPermissions import ca.allanwang.kau.utils.colorToForeground import ca.allanwang.kau.utils.copyFromInputStream import ca.allanwang.kau.utils.fadeOut import ca.allanwang.kau.utils.isHidden import ca.allanwang.kau.utils.isVisible import ca.allanwang.kau.utils.materialDialog import ca.allanwang.kau.utils.scaleXY import ca.allanwang.kau.utils.setIcon import ca.allanwang.kau.utils.tint import ca.allanwang.kau.utils.withAlpha import ca.allanwang.kau.utils.withMinAlpha import com.davemorrissey.labs.subscaleview.ImageSource import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import com.google.android.material.floatingactionbutton.FloatingActionButton import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.typeface.IIcon import com.pitchedapps.frost.R import com.pitchedapps.frost.facebook.FB_IMAGE_ID_MATCHER import com.pitchedapps.frost.facebook.get import com.pitchedapps.frost.facebook.requests.call import com.pitchedapps.frost.facebook.requests.getFullSizedImageUrl import com.pitchedapps.frost.facebook.requests.requestBuilder import com.pitchedapps.frost.services.LocalService import com.pitchedapps.frost.utils.ARG_COOKIE import com.pitchedapps.frost.utils.ARG_IMAGE_URL import com.pitchedapps.frost.utils.ARG_TEXT import com.pitchedapps.frost.utils.Prefs import com.pitchedapps.frost.utils.frostSnackbar import com.pitchedapps.frost.utils.frostUriFromFile import com.pitchedapps.frost.utils.isIndirectImageUrl import com.pitchedapps.frost.utils.logFrostEvent import com.pitchedapps.frost.utils.sendFrostEmail import com.pitchedapps.frost.utils.setFrostColors import com.sothree.slidinguppanel.SlidingUpPanelLayout import kotlinx.android.synthetic.main.activity_image.* import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File import java.io.FileNotFoundException import java.io.IOException import java.text.SimpleDateFormat import java.util.Date import java.util.Locale /** * Created by Allan Wang on 2017-07-15. */ class ImageActivity : KauBaseActivity() { @Volatile internal var errorRef: Throwable? = null /** * Reference to the temporary file path */ internal lateinit var tempFile: File /** * Reference to path for downloaded image * Nonnull once the image is downloaded by the user */ internal var savedFile: File? = null /** * Indicator for fab's click result */ internal var fabAction: FabStates = FabStates.NOTHING set(value) { if (field == value) return field = value value.update(image_fab) } companion object { /** * Cache folder to store images * Linked to the uri provider */ private const val IMAGE_FOLDER = "images" private const val TIME_FORMAT = "yyyyMMdd_HHmmss" private const val IMG_TAG = "Frost" private const val IMG_EXTENSION = ".png" const val PURGE_TIME: Long = 10 * 60 * 1000 // 10 min block private val L = KauLoggerExtension("Image", com.pitchedapps.frost.utils.L) fun cacheDir(context: Context): File = File(context.cacheDir, IMAGE_FOLDER) } private val cookie: String? by lazy { intent.getStringExtra(ARG_COOKIE) } val imageUrl: String by lazy { intent.getStringExtra(ARG_IMAGE_URL).trim('"') } private lateinit var trueImageUrl: Deferred private val imageText: String? by lazy { intent.getStringExtra(ARG_TEXT) } // a unique image identifier based on the id (if it exists), and its hash private val imageHash: String by lazy { "${Math.abs( FB_IMAGE_ID_MATCHER.find(imageUrl)[1]?.hashCode() ?: 0 )}_${Math.abs(imageUrl.hashCode())}" } private fun loadError(e: Throwable) { errorRef = e e.logFrostEvent("Image load error") if (image_progress.isVisible) image_progress.fadeOut() tempFile.delete() fabAction = FabStates.ERROR } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) intent?.extras ?: return finish() L.i { "Displaying image" } trueImageUrl = async(Dispatchers.IO) { val result = if (!imageUrl.isIndirectImageUrl) imageUrl else cookie?.getFullSizedImageUrl(imageUrl) ?: imageUrl if (result != imageUrl) L.v { "Launching with true url $result" } result } val layout = if (!imageText.isNullOrBlank()) R.layout.activity_image else R.layout.activity_image_textless setContentView(layout) image_container.setBackgroundColor( if (Prefs.blackMediaBg) Color.BLACK else Prefs.bgColor.withMinAlpha(222) ) image_text?.setTextColor(if (Prefs.blackMediaBg) Color.WHITE else Prefs.textColor) image_text?.setBackgroundColor( (if (Prefs.blackMediaBg) Color.BLACK else Prefs.bgColor) .colorToForeground(0.2f).withAlpha(255) ) image_text?.text = imageText image_progress.tint(if (Prefs.blackMediaBg) Color.WHITE else Prefs.accentColor) image_panel?.addPanelSlideListener(object : SlidingUpPanelLayout.SimplePanelSlideListener() { override fun onPanelSlide(panel: View, slideOffset: Float) { if (slideOffset == 0f && !image_fab.isShown) image_fab.show() else if (slideOffset != 0f && image_fab.isShown) image_fab.hide() image_text?.alpha = slideOffset / 2 + 0.5f } }) image_fab.setOnClickListener { fabAction.onClick(this) } image_photo.setOnImageEventListener(object : SubsamplingScaleImageView.DefaultOnImageEventListener() { override fun onImageLoadError(e: Exception) { loadError(e) } }) setFrostColors { themeWindow = false } tempFile = File(cacheDir(this), imageHash) launch(CoroutineExceptionHandler { _, throwable -> loadError(throwable) }) { downloadImageTo(tempFile) image_progress.fadeOut() image_photo.setImage(ImageSource.uri(frostUriFromFile(tempFile))) fabAction = FabStates.DOWNLOAD image_photo.animate().alpha(1f).scaleXY(1f).start() } } @Throws(IOException::class) private fun createPublicMediaFile(): File { val timeStamp = SimpleDateFormat(TIME_FORMAT, Locale.getDefault()).format(Date()) val imageFileName = "${IMG_TAG}_${timeStamp}_" val storageDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES) val frostDir = File(storageDir, IMG_TAG) if (!frostDir.exists()) frostDir.mkdirs() return File.createTempFile(imageFileName, IMG_EXTENSION, frostDir) } /** * Saves the image to the specified file, creating it if it doesn't exist. * Returns true if a change is made, false otherwise. * Throws an error if something goes wrong. */ @Throws(IOException::class) private suspend fun downloadImageTo(file: File): Boolean { val exceptionHandler = CoroutineExceptionHandler { _, err -> if (file.isFile && file.length() == 0L) { file.delete() } throw err } return withContext(Dispatchers.IO + exceptionHandler) { if (!file.isFile) { file.parentFile?.mkdirs() file.createNewFile() } else { file.setLastModified(System.currentTimeMillis()) } // Forbid overwrites if (file.isFile && file.length() > 0) { L.i { "Forbid image overwrite" } return@withContext false } // Fast route, image is already downloaded if (tempFile.isFile && tempFile.length() > 0) { if (tempFile == file) { return@withContext false } tempFile.copyTo(file, true) return@withContext true } // No temp file, download ourselves val response = cookie.requestBuilder() .url(trueImageUrl.await()) .get() .call() .execute() if (!response.isSuccessful) { throw IOException("Unsuccessful response for image: ${response.peekBody(128).string()}") } val body = response.body() ?: throw IOException("Failed to retrieve image body") file.copyFromInputStream(body.byteStream()) return@withContext true } } internal fun saveImage() { kauRequestPermissions(PERMISSION_WRITE_EXTERNAL_STORAGE) { granted, _ -> L.d { "Download image callback granted: $granted" } if (granted) { val errorHandler = CoroutineExceptionHandler { _, throwable -> loadError(throwable) frostSnackbar(R.string.image_download_fail) } launch(errorHandler) { val destination = createPublicMediaFile() downloadImageTo(destination) L.d { "Download image async finished" } scanMedia(destination) savedFile = destination frostSnackbar(R.string.image_download_success) fabAction = FabStates.SHARE } } } } override fun onDestroy() { LocalService.schedule(this, LocalService.Flag.PURGE_IMAGE) super.onDestroy() } } internal enum class FabStates( val iicon: IIcon, val iconColor: Int = Prefs.iconColor, val backgroundTint: Int = Int.MAX_VALUE ) { ERROR(GoogleMaterial.Icon.gmd_error, Color.WHITE, Color.RED) { override fun onClick(activity: ImageActivity) { val err = activity.errorRef?.takeIf { it !is FileNotFoundException && it.message != "Image failed to decode using JPEG decoder" } ?: return activity.materialDialog { title(R.string.kau_error) message(R.string.bad_image_overlay) positiveButton(R.string.kau_yes) { activity.sendFrostEmail(R.string.debug_image_link_subject) { addItem("Url", activity.imageUrl) addItem("Type", err.javaClass.name) addItem("Message", err.message ?: "Null") } } negativeButton(R.string.kau_no) } } }, NOTHING(GoogleMaterial.Icon.gmd_adjust) { override fun onClick(activity: ImageActivity) {} }, DOWNLOAD(GoogleMaterial.Icon.gmd_file_download) { override fun onClick(activity: ImageActivity) = activity.saveImage() }, SHARE(GoogleMaterial.Icon.gmd_share) { override fun onClick(activity: ImageActivity) { try { val photoURI = activity.frostUriFromFile(activity.savedFile!!) val intent = Intent(Intent.ACTION_SEND).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK putExtra(Intent.EXTRA_STREAM, photoURI) type = "image/png" } activity.startActivity(intent) } catch (e: Exception) { activity.errorRef = e e.logFrostEvent("Image share failed") activity.frostSnackbar(R.string.image_share_failed) } } }; /** * Change the fab look * If it's in view, give it some animations * * TODO investigate what is wrong with fadeScaleTransition * * https://github.com/AllanWang/KAU/issues/184 * */ fun update(fab: FloatingActionButton) { val tint = if (backgroundTint != Int.MAX_VALUE) backgroundTint else Prefs.accentColor if (fab.isHidden) { fab.setIcon(iicon, color = iconColor) fab.backgroundTintList = ColorStateList.valueOf(tint) fab.show() } else { fab.hide(object : FloatingActionButton.OnVisibilityChangedListener() { override fun onHidden(fab: FloatingActionButton) { fab.setIcon(iicon, color = iconColor) fab.show() } }) } } abstract fun onClick(activity: ImageActivity) }