diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 87ec31ecf..48e2411c6 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -23,6 +23,7 @@ #99000000 #4D000000 @color/black_transp_light + @color/black @color/black_transp_light @color/black_transp_light_darker #4D000000 diff --git a/build.gradle b/build.gradle index 33629e1b3..92c92e372 100644 --- a/build.gradle +++ b/build.gradle @@ -7,6 +7,7 @@ buildscript { google() jcenter() maven { url "https://dl.bintray.com/automattic/maven/" } + maven { url "https://jitpack.io" } } dependencies { classpath 'com.android.tools.build:gradle:3.5.1' @@ -30,6 +31,7 @@ allprojects { repositories { google() jcenter() + maven { url "https://jitpack.io" } } if (tasks.findByPath('checkstyle') == null) { diff --git a/photoeditor/build.gradle b/photoeditor/build.gradle index f9f107303..95cc93b7c 100644 --- a/photoeditor/build.gradle +++ b/photoeditor/build.gradle @@ -54,6 +54,10 @@ dependencies { implementation 'com.github.bumptech.glide:glide:4.10.0' kapt 'com.github.bumptech.glide:compiler:4.10.0' + implementation 'jp.wasabeef:glide-transformations:4.3.0' + + implementation 'com.github.chrisbanes:PhotoView:2.3.0' + implementation project(path: ':mp4compose') lintChecks 'org.wordpress:lint:1.0.1' diff --git a/photoeditor/src/main/java/com/automattic/photoeditor/views/PhotoEditorView.kt b/photoeditor/src/main/java/com/automattic/photoeditor/views/PhotoEditorView.kt index 637168fa6..f15e77bec 100644 --- a/photoeditor/src/main/java/com/automattic/photoeditor/views/PhotoEditorView.kt +++ b/photoeditor/src/main/java/com/automattic/photoeditor/views/PhotoEditorView.kt @@ -13,9 +13,11 @@ import android.view.View import android.view.ViewGroup import android.widget.ImageView import android.widget.ImageView.ScaleType.CENTER_CROP +import android.widget.ImageView.ScaleType.FIT_CENTER import android.widget.ProgressBar import android.widget.RelativeLayout import androidx.annotation.RequiresApi +import androidx.appcompat.widget.AppCompatImageView import com.automattic.photoeditor.OnSaveBitmap import com.automattic.photoeditor.R.styleable import com.automattic.photoeditor.views.background.fixed.BackgroundImageView @@ -24,6 +26,9 @@ import com.automattic.photoeditor.views.brush.BrushDrawingView import com.automattic.photoeditor.views.filter.CustomEffect import com.automattic.photoeditor.views.filter.ImageFilterView import com.automattic.photoeditor.views.filter.PhotoFilter +import com.bumptech.glide.Glide +import com.bumptech.glide.request.RequestOptions +import jp.wasabeef.glide.transformations.BlurTransformation /** * @@ -40,9 +45,11 @@ import com.automattic.photoeditor.views.filter.PhotoFilter class PhotoEditorView : RelativeLayout { private lateinit var autoFitTextureView: AutoFitTextureView private lateinit var backgroundImage: BackgroundImageView + private lateinit var backgroundImageBlurred: AppCompatImageView private lateinit var brushDrawingView: BrushDrawingView private lateinit var imageFilterView: ImageFilterView private lateinit var progressBar: ProgressBar + private var attachedToWindow: Boolean = false private var surfaceListeners: ArrayList = ArrayList() @@ -72,6 +79,9 @@ class PhotoEditorView : RelativeLayout { val source: ImageView get() = backgroundImage + val sourceBlurredBkg: ImageView + get() = backgroundImageBlurred + val brush: BrushDrawingView get() = brushDrawingView @@ -103,13 +113,33 @@ class PhotoEditorView : RelativeLayout { init(attrs) } + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + attachedToWindow = false + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + attachedToWindow = true + } + + fun onComposerDestroyed() { + attachedToWindow = false + } + @SuppressLint("Recycle") private fun init(attrs: AttributeSet?) { + backgroundImageBlurred = AppCompatImageView(context).apply { + id = imgBlurSrcId + adjustViewBounds = true + scaleType = CENTER_CROP + } + // Setup image attributes backgroundImage = BackgroundImageView(context).apply { id = imgSrcId adjustViewBounds = true - scaleType = CENTER_CROP + scaleType = FIT_CENTER } val imgSrcParam = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT).apply { @@ -159,6 +189,11 @@ class PhotoEditorView : RelativeLayout { backgroundImage.setOnImageChangedListener(object : BackgroundImageView.OnImageChangedListener { override fun onBitmapLoaded(sourceBitmap: Bitmap?) { + if (attachedToWindow) { + Glide.with(context).load(sourceBitmap) + .apply(RequestOptions.bitmapTransform(BlurTransformation(25, 3))) + .into(backgroundImageBlurred) + } imageFilterView.setFilterEffect(PhotoFilter.NONE) imageFilterView.setSourceBitmap(sourceBitmap) Log.d(TAG, "onBitmapLoaded() called with: sourceBitmap = [$sourceBitmap]") @@ -179,6 +214,9 @@ class PhotoEditorView : RelativeLayout { // Add camera preview addView(autoFitTextureView, cameraParam) + // Add image source + addView(backgroundImageBlurred, imgSrcParam) + // Add image source addView(backgroundImage, imgSrcParam) @@ -254,11 +292,13 @@ class PhotoEditorView : RelativeLayout { internal fun turnTextureViewOn() { backgroundImage.visibility = View.INVISIBLE + backgroundImageBlurred.visibility = View.INVISIBLE autoFitTextureView.visibility = View.VISIBLE } internal fun turnTextureViewOff() { backgroundImage.visibility = View.VISIBLE + backgroundImageBlurred.visibility = View.VISIBLE autoFitTextureView.visibility = View.INVISIBLE } @@ -266,11 +306,13 @@ class PhotoEditorView : RelativeLayout { backgroundImage.visibility = autoFitTextureView.visibility.also { autoFitTextureView.visibility = backgroundImage.visibility } + backgroundImageBlurred.visibility = backgroundImage.visibility return autoFitTextureView.visibility == View.VISIBLE } internal fun turnTextureAndImageViewOff() { backgroundImage.visibility = View.INVISIBLE + backgroundImageBlurred.visibility = View.INVISIBLE autoFitTextureView.visibility = View.INVISIBLE } @@ -286,6 +328,7 @@ class PhotoEditorView : RelativeLayout { companion object { private val TAG = "PhotoEditorView" + private val imgBlurSrcId = 5 private val imgSrcId = 1 private val brushSrcId = 2 private val glFilterId = 3 diff --git a/photoeditor/src/main/java/com/automattic/photoeditor/views/background/fixed/BackgroundImageView.kt b/photoeditor/src/main/java/com/automattic/photoeditor/views/background/fixed/BackgroundImageView.kt index ada546983..b60d64d34 100644 --- a/photoeditor/src/main/java/com/automattic/photoeditor/views/background/fixed/BackgroundImageView.kt +++ b/photoeditor/src/main/java/com/automattic/photoeditor/views/background/fixed/BackgroundImageView.kt @@ -10,14 +10,14 @@ import android.graphics.drawable.Drawable import android.graphics.drawable.Icon import android.net.Uri import android.util.AttributeSet -import androidx.appcompat.widget.AppCompatImageView +import com.github.chrisbanes.photoview.PhotoView /** * @author [Burhanuddin Rashid](https://github.com/burhanrashid52) * @version 0.1.2 * @since 5/21/2018 */ -internal class BackgroundImageView : AppCompatImageView { +class BackgroundImageView : PhotoView { private var mOnImageChangedListener: OnImageChangedListener? = null val bitmap: Bitmap? diff --git a/stories/build.gradle b/stories/build.gradle index f8261b93c..1c02f8175 100644 --- a/stories/build.gradle +++ b/stories/build.gradle @@ -52,6 +52,8 @@ dependencies { implementation 'com.github.bumptech.glide:glide:4.10.0' kapt 'com.github.bumptech.glide:compiler:4.10.0' + implementation 'jp.wasabeef:glide-transformations:4.3.0' + implementation 'org.greenrobot:eventbus:3.1.1' implementation project(path: ':photoeditor') @@ -63,6 +65,8 @@ dependencies { implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion" + implementation 'com.github.chrisbanes:PhotoView:2.3.0' + lintChecks 'org.wordpress:lint:1.0.1' testImplementation 'junit:junit:4.12' diff --git a/stories/src/main/java/com/wordpress/stories/compose/ComposeLoopFrameActivity.kt b/stories/src/main/java/com/wordpress/stories/compose/ComposeLoopFrameActivity.kt index 627257e83..e55e4b134 100644 --- a/stories/src/main/java/com/wordpress/stories/compose/ComposeLoopFrameActivity.kt +++ b/stories/src/main/java/com/wordpress/stories/compose/ComposeLoopFrameActivity.kt @@ -9,8 +9,10 @@ import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.ServiceConnection +import android.graphics.drawable.Drawable import android.graphics.Rect import android.graphics.drawable.ColorDrawable +import android.graphics.Matrix import android.hardware.Camera import android.media.MediaScannerConnection import android.net.Uri @@ -23,12 +25,16 @@ import android.os.Vibrator import android.provider.Settings import android.text.TextUtils import android.util.Log +import android.util.Size import android.view.GestureDetector import android.view.Gravity import android.view.MotionEvent import android.view.View import android.view.View.OnClickListener import android.webkit.MimeTypeMap +import android.widget.ImageView.ScaleType.CENTER_CROP +import android.widget.ImageView.ScaleType.FIT_CENTER +import android.widget.ImageView.ScaleType.FIT_START import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.constraintlayout.widget.Group @@ -63,8 +69,15 @@ import com.automattic.photoeditor.views.ViewType import com.automattic.photoeditor.views.ViewType.TEXT import com.automattic.photoeditor.views.added.AddedViewList import com.bumptech.glide.Glide +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.engine.GlideException +import com.bumptech.glide.load.resource.bitmap.BitmapTransformation +import com.bumptech.glide.load.resource.bitmap.FitCenter import com.bumptech.glide.load.resource.bitmap.CenterCrop import com.bumptech.glide.load.resource.bitmap.RoundedCorners +import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.target.Target +import com.github.chrisbanes.photoview.PhotoView import com.wordpress.stories.BuildConfig import com.wordpress.stories.R import com.wordpress.stories.compose.ComposeLoopFrameActivity.ExternalMediaPickerRequestCodesAndExtraKeys @@ -90,6 +103,7 @@ import com.wordpress.stories.compose.story.StoryFrameItem import com.wordpress.stories.compose.story.StoryFrameItem.BackgroundSource import com.wordpress.stories.compose.story.StoryFrameItem.BackgroundSource.FileBackgroundSource import com.wordpress.stories.compose.story.StoryFrameItem.BackgroundSource.UriBackgroundSource +import com.wordpress.stories.compose.story.StoryFrameItem.BackgroundViewInfo import com.wordpress.stories.compose.story.StoryFrameItemType import com.wordpress.stories.compose.story.StoryFrameItemType.IMAGE import com.wordpress.stories.compose.story.StoryFrameItemType.VIDEO @@ -105,14 +119,21 @@ import com.wordpress.stories.compose.text.TextStyleGroupManager import com.wordpress.stories.util.KEY_STORY_EDIT_MODE import com.wordpress.stories.util.KEY_STORY_SAVE_RESULT import com.wordpress.stories.util.STATE_KEY_CURRENT_STORY_INDEX +import com.wordpress.stories.util.TARGET_RATIO_9_16 +import com.wordpress.stories.util.calculateAspectRatioForDrawable import com.wordpress.stories.util.getDisplayPixelSize +import com.wordpress.stories.util.getSizeRatio import com.wordpress.stories.util.getStoryIndexFromIntentOrBundle +import com.wordpress.stories.util.isAspectRatioSimilarByPercentage +import com.wordpress.stories.util.isScreenTallerThan916 import com.wordpress.stories.util.isVideo +import com.wordpress.stories.util.normalizeSizeExportTo916 import kotlinx.android.synthetic.main.activity_composer.* import kotlinx.android.synthetic.main.content_composer.* import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode @@ -190,6 +211,7 @@ abstract class ComposeLoopFrameActivity : AppCompatActivity(), OnStoryFrameSelec private lateinit var backgroundSurfaceManager: BackgroundSurfaceManager private var currentOriginalCapturedFile: File? = null private lateinit var workingAreaRect: Rect + private var bottomOpaqueBarHeight: Int = 0 // default: no opaque bottom bar private val timesUpRunnable = Runnable { stopRecordingVideo(false) // time's up, it's not a cancellation @@ -235,6 +257,10 @@ abstract class ComposeLoopFrameActivity : AppCompatActivity(), OnStoryFrameSelec private var genericAnnouncementDialogProvider: GenericAnnouncementDialogProvider? = null private var showGenericAnnouncementDialogWhenReady = false private var useTempCaptureFile = true + private var screenWidth: Int = 1080 // default + private var screenHeight: Int = 1920 // default + private var screenSizeRatio: Float = TARGET_RATIO_9_16 + private lateinit var normalizedSize: Size private val connection = object : ServiceConnection { override fun onServiceConnected(className: ComponentName, service: IBinder) { @@ -329,6 +355,8 @@ abstract class ComposeLoopFrameActivity : AppCompatActivity(), OnStoryFrameSelec bottomNavigationBarMargin = insets.systemWindowInsetBottom workingAreaRect = calculateWorkingArea() photoEditor.updateWorkAreaRect(workingAreaRect) + bottomOpaqueBarHeight = preCalculateOpaqueBarHeight() + screenSizeRatio = getSizeRatio(screenWidth, screenHeight) delete_view.addBottomOffset(bottomNavigationBarMargin) delete_slide_view.addBottomOffset(bottomNavigationBarMargin) (bottom_strip_view as StoryFrameSelectorFragment).setBottomOffset(bottomNavigationBarMargin) @@ -540,13 +568,30 @@ abstract class ComposeLoopFrameActivity : AppCompatActivity(), OnStoryFrameSelec } } + private fun preCalculateOpaqueBarHeight(): Int { + // We used to obtain the screen dimensions by querying resources.displayMetrics but this value is the + // actual screen size minus the navigation bar height i.e. on a Pixel device we'd get 1794 instead of 1920. + // Given ComposeLoopFrameActivity is full screen, we can rely on the measuredHeight calculation instead. + screenWidth = photoEditorView.source.measuredWidth + screenHeight = photoEditorView.source.measuredHeight + normalizedSize = normalizeSizeExportTo916(screenWidth, screenHeight).toSize() + if (isScreenTallerThan916(screenWidth, screenHeight)) { + return (screenHeight - normalizedSize.height) + } else { + return 0 + } + } + + private fun setOpaqueBarHeight() { + if (bottomOpaqueBarHeight > 0) { + bottom_opaque_bar.layoutParams.height = bottomOpaqueBarHeight + } else { + bottom_opaque_bar.visibility = View.GONE + } + } + override fun onStart() { super.onStart() - val selectedFrameIndex = storyViewModel.getSelectedFrameIndex() - if (!launchCameraRequestPending && !launchVideoPlayerRequestPending && - selectedFrameIndex < storyViewModel.getCurrentStorySize()) { - updateBackgroundSurfaceUIWithStoryFrame(selectedFrameIndex) - } // upon loading an existing Story, show the generic announcement dialog if present if (showGenericAnnouncementDialogWhenReady) { showGenericAnnouncementDialogWhenReady = false @@ -717,6 +762,7 @@ abstract class ComposeLoopFrameActivity : AppCompatActivity(), OnStoryFrameSelec } override fun onDestroy() { + photoEditorView.onComposerDestroyed() doUnbindService() EventBus.getDefault().unregister(this) super.onDestroy() @@ -1108,6 +1154,7 @@ abstract class ComposeLoopFrameActivity : AppCompatActivity(), OnStoryFrameSelec private fun saveStoryPreHook() { showLoading() + refreshBackgroundViewInfoOnSelectedFrame() // disable layout change animations, we need this to make added views immediately visible, otherwise // we may end up capturing a Bitmap of a backing drawable that still has not been updated // (i.e. no visible added Views) @@ -1116,6 +1163,15 @@ abstract class ComposeLoopFrameActivity : AppCompatActivity(), OnStoryFrameSelec preHookRun = true } + private fun refreshBackgroundViewInfoOnSelectedFrame() { + storyViewModel.getSelectedFrame()?.let { + setBackgroundViewInfoOnFrame( + it, + photoEditor.composedCanvas.source as PhotoView + ) + } + } + private fun saveStoryPostHook(result: StorySaveResult) { doUnbindService() // re-enable layout change animations @@ -1575,9 +1631,12 @@ abstract class ComposeLoopFrameActivity : AppCompatActivity(), OnStoryFrameSelec private fun hideStoryFrameSelector() { (bottom_strip_view as StoryFrameSelectorFragment).hide() + bottom_opaque_bar.visibility = View.INVISIBLE } private fun showStoryFrameSelector() { + setOpaqueBarHeight() + bottom_opaque_bar.visibility = View.VISIBLE (bottom_strip_view as StoryFrameSelectorFragment).show() } @@ -1871,6 +1930,15 @@ abstract class ComposeLoopFrameActivity : AppCompatActivity(), OnStoryFrameSelec if (oldIndex >= 0) { // only remember added views for frame if current index is valid addCurrentViewsToFrameAtIndex(oldIndex) + + // save current imageMatrix as the background image may have been resized + val oldSelectedFrame = storyViewModel.getCurrentStoryFrameAt(oldIndex) + if (oldSelectedFrame?.frameItemType is IMAGE) { + setBackgroundViewInfoOnFrame( + oldSelectedFrame, + photoEditor.composedCanvas.source as PhotoView + ) + } // TODO add else clause and handle VIDEO frameItemType } // This is tricky. See https://stackoverflow.com/questions/45860434/cant-remove-view-from-root-view @@ -1899,11 +1967,7 @@ abstract class ComposeLoopFrameActivity : AppCompatActivity(), OnStoryFrameSelec if (newSelectedFrame.frameItemType is VIDEO) { showPlayVideoWithSurfaceSafeguard(source) } else { - val model = (source as? FileBackgroundSource)?.file ?: (source as UriBackgroundSource).contentUri - Glide.with(this@ComposeLoopFrameActivity) - .load(model) - .transform(CenterCrop()) - .into(photoEditorView.source) + loadImageWithGlideToPrepare(newSelectedFrame) showStaticBackground() } @@ -1922,8 +1986,154 @@ abstract class ComposeLoopFrameActivity : AppCompatActivity(), OnStoryFrameSelec showRetryButtonAndHideEditControlsForErroredFrame(newSelectedFrame.saveResultReason !is SaveSuccess) } + private fun setBackgroundViewInfoOnFrame(frame: StoryFrameItem, backgroundImageSource: PhotoView) { + val matrixValues = FloatArray(9) + val matrix = Matrix() + // fill in matrix with PhotoView Support matrix + backgroundImageSource.getSuppMatrix(matrix) + // extract matrix to float array matrixValues + matrix.getValues(matrixValues) + frame.source.backgroundViewInfo = BackgroundViewInfo( + imageMatrixValues = matrixValues, + scaleType = backgroundImageSource.scaleType + ) + } + + private fun setBackgroundViewInfoOnPhotoView(frame: StoryFrameItem, backgroundImageSource: PhotoView) { + val backgroundViewInfo = frame.source.backgroundViewInfo + // load image matrix from data if it exists + backgroundViewInfo?.let { + val matrix = Matrix() + matrix.setValues(it.imageMatrixValues) + backgroundImageSource.apply { + setSuppMatrix(matrix) + scaleType = it.scaleType + } + } + } + + private fun loadImageWithGlideToPrepare(frame: StoryFrameItem) { + val model = (frame.source as? FileBackgroundSource)?.file + ?: (frame.source as UriBackgroundSource).contentUri + + CoroutineScope(Dispatchers.IO).launch { + val futureTarget = Glide.with(this@ComposeLoopFrameActivity) + .asDrawable() + .load(model) + .fitCenter() // we use fitCenter at first (instead of cropping) so we don't lose any information + .submit(screenWidth, screenHeight) // we're not going to export images greater than the screen size + val drawable = futureTarget.get() + + val doAfterUse = object : ImageLoadedInterface { + override fun doAfter() { + // here setup the PhotoView support matrix + // we use a Handler because we need to set the support matrix only once the drawable + // has been set on the PhotoView, otherwise the matrix is not applied + // see + // https://github.com/Baseflow/PhotoView/blob/139a9ffeaf70bd628b015374cb6530fcf7d0bcb7/photoview/src/main/java/com/github/chrisbanes/photoview/PhotoViewAttacher.java#L279-L289 + // if we're all good, doAfter() will be callled on Glide's `onResourceReady`, so + // let's setup the ViewMatrix + Handler().post { + setBackgroundViewInfoOnPhotoView( + frame, + photoEditor.composedCanvas.source as PhotoView + ) + } + } + } + + withContext(Dispatchers.Main) { + // 1. if the image being loaded matches the aspect ratio of the device screen, use center_crop + // no parts would actually be cropped, given the matching aspect ratio it should fit + // except the opaque bar given we're normalizing to 9:16 on export + val drawableAspectRatio = calculateAspectRatioForDrawable(drawable) + bottom_opaque_bar.visibility = View.VISIBLE + if (isAspectRatioSimilarByPercentage(drawableAspectRatio, screenSizeRatio, 0.01f)) { + photoEditorView.source.scaleType = CENTER_CROP + loadImageWithGlideToDraw(drawable, CenterCrop(), screenWidth, screenHeight, doAfterUse) + } else { + // 2. if the device is taller than 9:16, and image is portrait + // just crop the bottom (showing the opaque bar) + if (isScreenTallerThan916(screenWidth, screenHeight) && + (drawable.intrinsicHeight > drawable.intrinsicWidth) + ) { + val transformToUse = if (drawable.intrinsicWidth >= screenWidth) { + // this aligns to top so there's no top black bar + photoEditorView.source.scaleType = FIT_START + null + } else { + photoEditorView.source.scaleType = FIT_CENTER + FitCenter() + } + loadImageWithGlideToDraw(drawable, transformToUse, + normalizedSize.width, normalizedSize.height, doAfterUse) + } else { + // 3. else, load with fit-center (black bars on the side that doesn't fit) + // see https://developer.android.com/reference/android/graphics/Matrix.ScaleToFit#CENTER + photoEditorView.source.scaleType = FIT_CENTER + loadImageWithGlideToDraw(drawable, FitCenter(), screenWidth, screenHeight, doAfterUse) + } + } + } + } + } + + private suspend fun loadImageWithGlideToDraw( + drawable: Drawable, + transformToUse: BitmapTransformation? = null, + overrideWidth: Int, + overrideHeight: Int, + doAfterUse: ImageLoadedInterface + ) { + withContext(Dispatchers.Main) { + transformToUse?.let { + Glide.with(this@ComposeLoopFrameActivity) + .load(drawable) + .transform(it) + .listener(provideGlideRequestListener(doAfterUse)) + .override(overrideWidth, overrideHeight) + .into(photoEditorView.source) + } ?: Glide.with(this@ComposeLoopFrameActivity) + .load(drawable) + .listener(provideGlideRequestListener(doAfterUse)) + .override(overrideWidth, overrideHeight) + .into(photoEditorView.source) + } + } + + interface ImageLoadedInterface { + fun doAfter() + } + + private fun provideGlideRequestListener(callback: ImageLoadedInterface): RequestListener { + return object : RequestListener { + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target?, + isFirstResource: Boolean + ): Boolean { + // let the default implementation run + return false + } + + override fun onResourceReady( + resource: Drawable?, + model: Any?, + target: Target?, + dataSource: DataSource?, + isFirstResource: Boolean + ): Boolean { + callback.doAfter() + // return false to let Glide proceed and set the drawable + return false + } + } + } + override fun onStoryFrameAddTapped() { addCurrentViewsToFrameAtIndex(storyViewModel.getSelectedFrameIndex()) + refreshBackgroundViewInfoOnSelectedFrame() showMediaPicker() } diff --git a/stories/src/main/java/com/wordpress/stories/compose/frame/FrameSaveManager.kt b/stories/src/main/java/com/wordpress/stories/compose/frame/FrameSaveManager.kt index cbb3f2538..5748128f5 100644 --- a/stories/src/main/java/com/wordpress/stories/compose/frame/FrameSaveManager.kt +++ b/stories/src/main/java/com/wordpress/stories/compose/frame/FrameSaveManager.kt @@ -3,14 +3,19 @@ package com.wordpress.stories.compose.frame import android.content.Context import android.graphics.Bitmap import android.graphics.Color +import android.graphics.Matrix import android.net.Uri import android.view.View import android.view.ViewGroup.LayoutParams +import android.widget.ImageView.ScaleType.CENTER_CROP +import android.widget.ImageView.ScaleType.FIT_CENTER +import android.widget.ImageView.ScaleType.FIT_START import android.widget.RelativeLayout import com.automattic.photoeditor.PhotoEditor import com.automattic.photoeditor.PhotoEditor.OnSaveWithCancelAndProgressListener import com.automattic.photoeditor.views.PhotoEditorView import com.automattic.photoeditor.views.ViewType.STICKER_ANIMATED +import com.automattic.photoeditor.views.background.fixed.BackgroundImageView import com.wordpress.stories.compose.story.StoryFrameItem import com.wordpress.stories.compose.story.StoryFrameItem.BackgroundSource.FileBackgroundSource import com.wordpress.stories.compose.story.StoryFrameItem.BackgroundSource.UriBackgroundSource @@ -20,8 +25,8 @@ import com.wordpress.stories.compose.story.StoryFrameItemType.VIDEO import com.wordpress.stories.util.cloneViewSpecs import com.wordpress.stories.util.removeViewFromParent import com.bumptech.glide.Glide -import com.bumptech.glide.load.resource.bitmap.CenterCrop import com.bumptech.glide.request.FutureTarget +import com.bumptech.glide.request.RequestOptions import com.wordpress.stories.util.isSizeRatio916 import com.wordpress.stories.util.normalizeSizeExportTo916 import kotlinx.coroutines.CoroutineScope @@ -36,6 +41,7 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.yield import java.io.File import kotlin.coroutines.CoroutineContext +import jp.wasabeef.glide.transformations.BlurTransformation typealias FrameIndex = Int @@ -142,8 +148,13 @@ class FrameSaveManager( } else { try { // create ghost PhotoEditorView to be used for saving off-screen + val originalMatrix = Matrix() + frame.source.backgroundViewInfo?.let { + originalMatrix.setValues(it.imageMatrixValues) + } + val ghostPhotoEditorView = createGhostPhotoEditor(context, photoEditor.composedCanvas) - frameFile = saveImageFrame(context, frame, ghostPhotoEditorView, frameIndex) + frameFile = saveImageFrame(context, frame, ghostPhotoEditorView, originalMatrix, frameIndex) frame.composedFrameFile = frameFile saveProgressListener?.onFrameSaveCompleted(frameIndex, frame) } catch (ex: Exception) { @@ -167,10 +178,11 @@ class FrameSaveManager( context: Context, frame: StoryFrameItem, ghostPhotoEditorView: PhotoEditorView, + originalMatrix: Matrix, frameIndex: FrameIndex ): File { // prepare the ghostview with its background image and the AddedViews on top of it - val futureTarget = preparePhotoEditorViewForSnapshot(context, frame, ghostPhotoEditorView) + val futureTargetPair = preparePhotoEditorViewForSnapshot(context, frame, originalMatrix, ghostPhotoEditorView) val file = withContext(Dispatchers.IO) { if (normalizeTo916 && !isSizeRatio916(ghostPhotoEditorView.width, ghostPhotoEditorView.height)) { @@ -188,7 +200,8 @@ class FrameSaveManager( } releaseAddedViewsAfterSnapshot(frame) - Glide.with(context).clear(futureTarget) + Glide.with(context).clear(futureTargetPair.first) + Glide.with(context).clear(futureTargetPair.second) return file } @@ -276,21 +289,71 @@ class FrameSaveManager( private suspend fun preparePhotoEditorViewForSnapshot( context: Context, frame: StoryFrameItem, + originalMatrix: Matrix, ghostPhotoEditorView: PhotoEditorView - ): FutureTarget { + ): Pair, FutureTarget> { // prepare background val uri = (frame.source as? UriBackgroundSource)?.contentUri ?: (frame.source as FileBackgroundSource).file + // ----------------------------------- + // first set the background blurred image + val targetBlurredView = ghostPhotoEditorView.sourceBlurredBkg + + // making use of Glide to decode bitmap and get the right orientation automatically + // http://bumptech.github.io/glide/doc/getting-started.html#background-threads + val futureBlurredTarget = Glide.with(context) + .asBitmap() + .load(uri) + .apply(RequestOptions.bitmapTransform(BlurTransformation(25, 3))) + .submit(targetBlurredView.measuredWidth, targetBlurredView.measuredHeight) + targetBlurredView.setImageBitmap(futureBlurredTarget.get()) + + // ----------------------------------- + // now set the actual background image + val targetView = ghostPhotoEditorView.source + val scaleType = frame.source.backgroundViewInfo?.scaleType + // making use of Glide to decode bitmap and get the right orientation automatically // http://bumptech.github.io/glide/doc/getting-started.html#background-threads - val futureTarget = Glide.with(context) - .asBitmap() - .load(uri) - .transform(CenterCrop()) // also use CenterCrop as it's the same the user was seeing as per WYSIWYG - .submit(ghostPhotoEditorView.source.measuredWidth, ghostPhotoEditorView.source.measuredHeight) + val futureTarget = when (scaleType) { + FIT_START -> + Glide.with(context) + .asBitmap() + .load(uri) + // no transform used when FIT_START, see correlation in ComposeLoopFrameActivity's + // loadImageWithGlideToPrepare() + .submit(targetView.measuredWidth, targetView.measuredHeight) + FIT_CENTER -> + Glide.with(context) + .asBitmap() + .load(uri) + .fitCenter() // we use fitCenter at first (instead of cropping) so we don't lose any information + .submit(targetView.measuredWidth, targetView.measuredHeight) + CENTER_CROP -> + Glide.with(context) + .asBitmap() + .load(uri) + .centerCrop() // we use fitCenter at first (instead of cropping) so we don't lose any information + .submit(targetView.measuredWidth, targetView.measuredHeight) + else -> // default case with no transform needed so futureTarget is initialized, + // but we don't really expect to get this case + Glide.with(context) + .asBitmap() + .load(uri) + .submit(targetView.measuredWidth, targetView.measuredHeight) + } val bitmap = futureTarget.get() - ghostPhotoEditorView.source.setImageBitmap(bitmap) + targetView.setImageBitmap(bitmap) + + // IMPORTANT: scaleType and setSuppMatrix should only be called _after_ the bitmap is set on the targetView + // by means of targetView.setImageBitmap(). Calling this before will have no effect due to PhotoView's checks. + (targetView as BackgroundImageView).apply { + frame.source.backgroundViewInfo?.let { + this.scaleType = it.scaleType + } + setSuppMatrix(originalMatrix) + } // removeViewFromParent for views that were added in the UI thread need to also run on the main thread // otherwise we'd get a android.view.ViewRootImpl$CalledFromWrongThreadException: @@ -308,7 +371,7 @@ class FrameSaveManager( } } } - return futureTarget + return Pair, FutureTarget>(futureTarget, futureBlurredTarget) } private fun getViewLayoutParams(): LayoutParams { diff --git a/stories/src/main/java/com/wordpress/stories/compose/story/StoryFrameItem.kt b/stories/src/main/java/com/wordpress/stories/compose/story/StoryFrameItem.kt index f47fb9370..a9e8a33f6 100644 --- a/stories/src/main/java/com/wordpress/stories/compose/story/StoryFrameItem.kt +++ b/stories/src/main/java/com/wordpress/stories/compose/story/StoryFrameItem.kt @@ -1,6 +1,7 @@ package com.wordpress.stories.compose.story import android.net.Uri +import android.widget.ImageView.ScaleType import com.automattic.photoeditor.views.added.AddedView import com.automattic.photoeditor.views.added.AddedViewList import com.wordpress.stories.compose.frame.StorySaveEvents.SaveResultReason @@ -27,8 +28,15 @@ data class StoryFrameItem( var composedFrameFile: File? = null, var id: String? = null ) { + @Serializable + data class BackgroundViewInfo( + val imageMatrixValues: FloatArray, + val scaleType: ScaleType + ) + @Serializable sealed class BackgroundSource { + var backgroundViewInfo: BackgroundViewInfo? = null @Serializable data class UriBackgroundSource( @Serializable(with = UriSerializer::class) diff --git a/stories/src/main/java/com/wordpress/stories/util/ViewUtils.kt b/stories/src/main/java/com/wordpress/stories/util/ViewUtils.kt index 78175618c..b821edb1a 100644 --- a/stories/src/main/java/com/wordpress/stories/util/ViewUtils.kt +++ b/stories/src/main/java/com/wordpress/stories/util/ViewUtils.kt @@ -1,5 +1,6 @@ package com.wordpress.stories.util +import android.graphics.drawable.Drawable import android.util.Size import android.view.View import android.view.ViewGroup @@ -39,6 +40,10 @@ fun isSizeRatio916(originalWidth: Int, originalHeight: Int): Boolean { return (originalWidth.toFloat() / originalHeight.toFloat()) == TARGET_RATIO_9_16 } +fun isScreenTallerThan916(originalWidth: Int, originalHeight: Int): Boolean { + return (originalWidth.toFloat() / originalHeight.toFloat()) < TARGET_RATIO_9_16 +} + fun normalizeSizeExportTo916(originalWidth: Int, originalHeight: Int): ScreenSize { /* 1. if the screen is 16:9, we're OK @@ -65,3 +70,16 @@ fun normalizeSizeExportTo916(originalWidth: Int, originalHeight: Int): ScreenSiz } } } + +fun calculateAspectRatioForDrawable(drawable: Drawable): Float { + val width = drawable.intrinsicWidth + val height = drawable.intrinsicHeight + return width.toFloat() / height.toFloat() +} + +fun isAspectRatioSimilarByPercentage(aspectRatio1: Float, aspectRatio2: Float, percentage: Float): Boolean { + return (Math.abs(aspectRatio1 - aspectRatio2) < percentage) +} +fun getSizeRatio(originalWidth: Int, originalHeight: Int): Float { + return (originalWidth.toFloat() / originalHeight.toFloat()) +} diff --git a/stories/src/main/res/layout/content_composer.xml b/stories/src/main/res/layout/content_composer.xml index d18431d0c..10fadc358 100644 --- a/stories/src/main/res/layout/content_composer.xml +++ b/stories/src/main/res/layout/content_composer.xml @@ -20,6 +20,15 @@ android:animateLayoutChanges="true" android:background="@color/black" /> + + #66000000 #99000000 @color/black_transp_light + @color/black @color/black_transp_light @color/black_transp_light_darker