Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introducing Frame Saving process into a Service #278

Merged
Merged
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 {
jd-alexander marked this conversation as resolved.
Show resolved Hide resolved
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
}
}
}