Skip to content

Commit

Permalink
Merge pull request #278 from Automattic/issue/273-save-frame-service
Browse files Browse the repository at this point in the history
Introducing Frame Saving process into a Service
  • Loading branch information
mzorz authored Mar 20, 2020
2 parents 33f5b5f + 51ae33c commit 8e44922
Show file tree
Hide file tree
Showing 5 changed files with 210 additions and 30 deletions.
2 changes: 2 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ dependencies {
implementation 'com.github.bumptech.glide:glide:4.9.0'
kapt 'com.github.bumptech.glide:compiler:4.9.0'

implementation 'org.greenrobot:eventbus:3.1.1'

implementation 'io.sentry:sentry-android:1.7.27'
// this dependency is not required if you are already using your own
// slf4j implementation
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@
android:theme="@style/AppTheme.NoActionBar"
/>

<!-- Services -->
<service
android:name=".compose.frame.FrameSaveService"
android:label="Frame Save Service" />

<!-- FileProvider used to share photos with other apps -->
<provider
android:name="androidx.core.content.FileProvider"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
package com.automattic.portkey.compose

import android.Manifest
import android.animation.LayoutTransition
import android.annotation.SuppressLint
import android.app.Activity
import android.app.ActivityOptions
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.hardware.Camera
import android.media.MediaScannerConnection
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.IBinder
import android.os.VibrationEffect
import android.os.Vibrator
import android.text.TextUtils
Expand Down Expand Up @@ -51,7 +55,8 @@ import com.automattic.portkey.BuildConfig
import com.automattic.portkey.R
import com.automattic.portkey.compose.emoji.EmojiPickerFragment
import com.automattic.portkey.compose.emoji.EmojiPickerFragment.EmojiListener
import com.automattic.portkey.compose.frame.FrameSaveManager
import com.automattic.portkey.compose.frame.FrameSaveService
import com.automattic.portkey.compose.frame.FrameSaveService.StorySaveResult
import com.automattic.portkey.compose.photopicker.MediaBrowserType
import com.automattic.portkey.compose.photopicker.PhotoPickerActivity
import com.automattic.portkey.compose.photopicker.PhotoPickerFragment
Expand All @@ -76,9 +81,9 @@ import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import com.google.android.material.snackbar.Snackbar
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 org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import java.io.File
import java.io.IOException
import kotlin.math.abs
Expand Down Expand Up @@ -120,11 +125,30 @@ class ComposeLoopFrameActivity : AppCompatActivity(), OnStoryFrameSelectorTapped
private var isEditingText: Boolean = false

private lateinit var storyViewModel: StoryViewModel
private lateinit var frameSaveManager: FrameSaveManager
private lateinit var transition: LayoutTransition

private lateinit var frameSaveService: FrameSaveService
private var saveServiceBound: Boolean = false

private val connection = object : ServiceConnection {
override fun onServiceConnected(className: ComponentName, service: IBinder) {
Log.d("ComposeLoopFrame", "onServiceConnected()")
val binder = service as FrameSaveService.FrameSaveServiceBinder
frameSaveService = binder.getService()
frameSaveService.saveStoryFrames(0, photoEditor, StoryRepository.getImmutableCurrentStoryFrames())
saveServiceBound = true
}

override fun onServiceDisconnected(arg0: ComponentName) {
Log.d("ComposeLoopFrame", "onServiceDisconnected()")
saveServiceBound = false
}
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_composer)
EventBus.getDefault().register(this)

topControlsBaseTopMargin = getLayoutTopMarginBeforeInset(edit_mode_controls.layoutParams)
ViewCompat.setOnApplyWindowInsetsListener(compose_loop_frame_layout) { view, insets ->
Expand Down Expand Up @@ -202,8 +226,6 @@ class ComposeLoopFrameActivity : AppCompatActivity(), OnStoryFrameSelectorTapped
}
})

frameSaveManager = FrameSaveManager(photoEditor)

backgroundSurfaceManager = BackgroundSurfaceManager(
savedInstanceState,
lifecycle,
Expand Down Expand Up @@ -294,7 +316,8 @@ class ComposeLoopFrameActivity : AppCompatActivity(), OnStoryFrameSelectorTapped
}

override fun onDestroy() {
frameSaveManager.onCancel()
doUnbindService()
EventBus.getDefault().unregister(this)
super.onDestroy()
}

Expand Down Expand Up @@ -525,12 +548,7 @@ class ComposeLoopFrameActivity : AppCompatActivity(), OnStoryFrameSelectorTapped
if (storyViewModel.getCurrentStorySize() > 0) {
// save all composed frames
if (PermissionUtils.checkAndRequestPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
CoroutineScope(Dispatchers.Main).launch {
showLoading(getString(R.string.label_saving))
saveStory()
hideLoading()
showToast("READY")
}
saveStory()
}
}
}
Expand All @@ -540,27 +558,35 @@ class ComposeLoopFrameActivity : AppCompatActivity(), OnStoryFrameSelectorTapped
}
}

private suspend fun saveStory() {
private fun saveStory() {
saveStoryPreHook()
// Bind to FrameSaveService
FrameSaveService.startServiceAndGetSaveStoryIntent(this).also { intent ->
bindService(intent, connection, Context.BIND_AUTO_CREATE)
}
}

private fun saveStoryPreHook() {
showLoading(getString(R.string.label_saving))
// 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)
val transition = photoEditorView.getLayoutTransition()
transition = photoEditorView.getLayoutTransition()
photoEditorView.layoutTransition = null
}

val frameFileList =
frameSaveManager.saveStory(
this@ComposeLoopFrameActivity,
storyViewModel.getImmutableCurrentStoryFrames()
)
// once all frames have been saved, issue a broadcast so the system knows these frames are ready
sendNewStoryReadyBroadcast(frameFileList)
private fun saveStoryPostHook() {
doUnbindService()

// given saveStory for static images works with a ghost off screen buffer by removing / adding views to it,
// we need to refresh the selection so added views get properly re-added after frame iteration ends
refreshStoryFrameSelection()

// re-enable layout change animations
photoEditorView.layoutTransition = transition

hideLoading()
showToast("READY")
}

private fun refreshStoryFrameSelection() {
Expand Down Expand Up @@ -1080,7 +1106,7 @@ class ComposeLoopFrameActivity : AppCompatActivity(), OnStoryFrameSelectorTapped
// level >= 24, so if you only target 24+ you can remove this statement
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
@Suppress("DEPRECATION")
if (mediaFile.extension.startsWith("jpg")) {
if (mediaFile.extension == "jpg") {
sendBroadcast(Intent(Camera.ACTION_NEW_PICTURE, Uri.fromFile(mediaFile)))
} else {
sendBroadcast(Intent(Camera.ACTION_NEW_VIDEO, Uri.fromFile(mediaFile)))
Expand All @@ -1103,7 +1129,7 @@ class ComposeLoopFrameActivity : AppCompatActivity(), OnStoryFrameSelectorTapped
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
@Suppress("DEPRECATION")
for (mediaFile in mediaFileList) {
if (mediaFile.extension.startsWith("jpg")) {
if (mediaFile.extension == "jpg") {
sendBroadcast(Intent(Camera.ACTION_NEW_PICTURE, Uri.fromFile(mediaFile)))
} else {
sendBroadcast(Intent(Camera.ACTION_NEW_VIDEO, Uri.fromFile(mediaFile)))
Expand Down Expand Up @@ -1262,6 +1288,19 @@ class ComposeLoopFrameActivity : AppCompatActivity(), OnStoryFrameSelectorTapped
}
}

private fun doUnbindService() {
if (saveServiceBound) {
unbindService(connection)
saveServiceBound = false
}
}

@Subscribe(threadMode = ThreadMode.MAIN)
fun onStorySaveResult(event: StorySaveResult) {
// TODO do something to treat the errors here
saveStoryPostHook()
}

companion object {
private const val FRAGMENT_DIALOG = "dialog"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,9 @@ class FrameSaveManager(private val photoEditor: PhotoEditor) : CoroutineScope {
): List<File?> {
// first, launch all frame save processes async
return frames.mapIndexed { index, frame ->
withContext(coroutineContext) {
async {
yield()
saveLoopFrame(context, frame, index)
}
async {
yield()
saveLoopFrame(context, frame, index)
}
}.awaitAll()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package com.automattic.portkey.compose.frame

import android.app.Service
import android.content.Context
import android.content.Intent
import android.hardware.Camera
import android.media.MediaScannerConnection
import android.net.Uri
import android.os.Binder
import android.os.Build
import android.os.IBinder
import android.util.Log
import android.webkit.MimeTypeMap
import com.automattic.photoeditor.PhotoEditor
import com.automattic.portkey.compose.story.StoryFrameItem
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.io.File
import org.greenrobot.eventbus.EventBus

class FrameSaveService : Service() {
private val binder = FrameSaveServiceBinder()
private var storyIndex: Int = 0
private lateinit var frameSaveManager: FrameSaveManager

override fun onCreate() {
super.onCreate()
// TODO add logging
Log.d("FrameSaveService", "onCreate()")
}

override fun onBind(p0: Intent?): IBinder? {
Log.d("FrameSaveService", "onBind()")
return binder
}

// we won't really use intents to start the Service but we need it to be a started Service so we can make it
// a foreground Service as well. Hence, here we override onStartCommand() as well.
// So basically we're using a bound Service to be able to pass the FrameSaveManager instance to it, which in turn
// has an instance of PhotoEditor, which is needed to save each frame.
// And, we're making it a started Service so we can also make it a foreground Service (bound services alone
// can't be foreground services, and serializing a FrameSaveManager or a PhotoEditor instance seems way too
// for something that will need to live on the screen anyway, at least for the time being).
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d("FrameSaveService", "onStartCommand()")
// Skip this request if no items to upload were given
if (intent == null) { // || !intent.hasExtra(KEY_MEDIA_LIST) && !intent.hasExtra(KEY_LOCAL_POST_ID)) {
// AppLog.e(T.MAIN, "UploadService > Killed and restarted with an empty intent")
stopSelf()
}
return START_NOT_STICKY
}

fun saveStoryFrames(storyIndex: Int, photoEditor: PhotoEditor, frames: List<StoryFrameItem>) {
this.storyIndex = storyIndex
this.frameSaveManager = FrameSaveManager(photoEditor)
CoroutineScope(Dispatchers.Default).launch {
saveStoryFramesAndDispatchNewFileBroadcast(frameSaveManager, frames)
stopSelf()
}
}

private suspend fun saveStoryFramesAndDispatchNewFileBroadcast(
frameSaveManager: FrameSaveManager,
frames: List<StoryFrameItem>
) {
val frameFileList =
frameSaveManager.saveStory(
this,
frames
)

// once all frames have been saved, issue a broadcast so the system knows these frames are ready
sendNewMediaReadyBroadcast(frameFileList)

// TODO collect all the errors somehow before posting the SaveResult for the whole Story
EventBus.getDefault().post(StorySaveResult(true, storyIndex))
}

private fun sendNewMediaReadyBroadcast(rawMediaFileList: List<File?>) {
// Implicit broadcasts will be ignored for devices running API
// level >= 24, so if you only target 24+ you can remove this statement
val mediaFileList = rawMediaFileList.filterNotNull()
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
@Suppress("DEPRECATION")
for (mediaFile in mediaFileList) {
if (mediaFile.extension == "jpg") {
sendBroadcast(Intent(Camera.ACTION_NEW_PICTURE, Uri.fromFile(mediaFile)))
} else {
sendBroadcast(Intent(Camera.ACTION_NEW_VIDEO, Uri.fromFile(mediaFile)))
}
}
}

val arrayOfmimeTypes = arrayOfNulls<String>(mediaFileList.size)
val arrayOfPaths = arrayOfNulls<String>(mediaFileList.size)
for ((index, mediaFile) in mediaFileList.withIndex()) {
arrayOfmimeTypes[index] = MimeTypeMap.getSingleton()
.getMimeTypeFromExtension(mediaFile.extension)
arrayOfPaths[index] = mediaFile.absolutePath
}

// If the folder selected is an external media directory, this is unnecessary
// but otherwise other apps will not be able to access our images unless we
// scan them using [MediaScannerConnection]
MediaScannerConnection.scanFile(
applicationContext, arrayOfPaths, arrayOfmimeTypes, null)
}

override fun onDestroy() {
Log.d("FrameSaveService", "onDestroy()")
frameSaveManager.onCancel()
super.onDestroy()
}

inner class FrameSaveServiceBinder : Binder() {
fun getService(): FrameSaveService = this@FrameSaveService
}

data class StorySaveResult(
var success: Boolean,
var storyIndex: Int,
var frameSaveResult: List<FrameSaveResult>? = null
)
data class FrameSaveResult(val success: Boolean, val frameIndex: Int)

companion object {
fun startServiceAndGetSaveStoryIntent(context: Context): Intent {
Log.d("FrameSaveService", "startServiceAndGetSaveStoryIntent()")
val intent = Intent(context, FrameSaveService::class.java)
context.startService(intent)
return intent
}
}
}

0 comments on commit 8e44922

Please sign in to comment.