A quick rundown of the major improvements made to my video snipping tool

A quick rundown of the major improvements made to my video snipping tool

I revisted one of my projects from 4 months ago, and here's a breakdown of the improvements made so far

I took some time to work on my existing projects, and came across some potential problems I didn't realize for a long time. ClipClip was developed to trim videos into chunks of equal parts, mostly suitable for sharing correctly synced videos on WhatsApp statuses, and one of the core features of this project was giving the users the ability to select a custom start and end time needed to split into parts. At this point, the challenges of this project was divided into 2 phases:

  1. A reliable tool that would execute the splitting operations on videos
  2. A custom UI tool for selecting timestamp ranges

FFMPEG is a popular software for editing and manipulating videos, so this was my go-to tool to use for this project. Being my first time creating custom android views, I spent some time developing the custom slider tool for selecting time ranges. The first implementation appeared to work well, and after some time with learning and practicing with custom views, I discovered some problems with the nature and behaviour of the bloated code. Here's a screenshot of what it looked like, and a link to the source code. Initial screen layout

This writeup is divided into two phases of major development:

  1. The SelectionView
  2. Foreground Services & Work Manager

1. The Selection View

I used a ViewGroup for the selection view, and created custom View components for the thumb markers and progress marker. My approach to this involved a bunch of unnecessary callbacks between the viewgroup and the marker views.

For the recent release, I rewrote the selection view utilizing a single View component that manages all interactions including marker sliding and view dragging. Instead of using child views as markers in the SelectionView, I created variables that represents the coordinates of the markers. These coordinates are made visible to the user as touchable regions drawn on the screen with the Canvas object provided in the onDraw() method. The touch movements are captured within the CustomSelectionView region, and the values obtained are used to calculate the expected position values for the thumb coordinates based on where the touch events occurred. When these coordinates change, the canvas redraws them on screen after a call to invalidate() to show the new position of the thumb markers. Screenshowing the new SelectionView.png

About the Thumb Markers

A thing to note about the markers is that the start and end values are initialized as 0 and width value of the view respectively. However, these values are not solely used to determine percentage offsets of the start and end values of the thumb markers and the reason is based on the expected behaviour of the selection view. The SelectionView is expected to capture all possible seconds from a video, and the thumb markers (including the touchable region) already occupy some space from the total size of the SelectionView. So, if we are to use 0 and the width of the view to represent the mininum and maximum values respectively within the slider, everything would be fine as long as we allow for intersection between the two markers.

However, we obviously wouldn't want that since it would be a bit difficult for users to properly select ranges that are between the marker coordinates and the size of the marker. The solution is to prevent the respective markers from intersecting each other. That means the minimum value for the start marker would be 0, and the maximum value for it would be --> the end marker coordinates - size of its marker.

    /**Kotlin
     * Returns the maximum permissible value of the left thumb
     * i.e ranges between 0 and the start of the
     * right thumb
     */
    private fun getMinMaxLeft(positionX: Float): Float {
        return max(0f, min(positionX, thumbEnd - (SIZE * 2)))
    }

Also the minimum value for the end marker would be --> the x coordinate of the start marker + size of the marker, while the maximum value would be the width of the selection view.

    /**
     * Returns the maximum permissible value of the right thumb
     * i.e not exceeding the width of the screen and the end of the
     * left thumb
     */
    private fun getMinMaxRight(positionX: Float): Float {
        return max(thumbStart + (SIZE * 2), min(positionX, width.toFloat()))
    }

Using this approach brings in another problem that needs to be resolved. Calculating percentage offset for both thumb markers as coordinate_value/width would be incorrect since we've factored the sizes of both thumbs from SelectionView width to represent the touchable regions for the thumbs from their respective start coordinates.

    /**
     * returns true if the touch is between the start thumb
     * position plus width of the thumb
     */
    private fun isLeft(positionX: Float): Boolean {
        return (positionX in thumbStart..thumbStart + SIZE)
    }

    /**
     * returns true if the touch is between the end thumb
     * position minus width of the thumb
     */
    private fun isRight(positionX: Float): Boolean {
        return (positionX in thumbEnd - SIZE..thumbEnd)
    }

Doing this means that it would be impossible to get 0.0 value when both markers meet due to the nature of the real and apparent coordinate values of the start and end thumb. Look at the image below as an example. Both markers meet, but instead of the expected time difference to be 00:00:00:000, it shows 00:00:20:615 because the offset is still being calculated with the real coordinates and width of the SelectionView. Screen showing the markers meeting at a point

The way around this was to factor in the size of both thumb markers and subtract them from the actual width of the view to represent the apparent total width.

    /**
     * Returns the width of the draggable region
     */
    private fun getActualWidth(): Float {
        return width - (SIZE * 2)
    }

This directly affects the correct values for the start and end coordinates when calculating the percentage offsets relative to the SelectionView size. So to obtain the correct results, the thumb start coordinates remains as is since the starting point is from 0. However the end coordinates needs to be altered to match up with the new maximum value declared in the code above. In the getActualWidth() function, the total width was reduced by the size of the two thumb markers, so this same value would have to be subtracted from the end coordinate.

    private fun getThumbEnd(): Float {
        return (thumbEnd - (SIZE * 2))
    }

Now, the distance offset of the start and end coordinates can be calculated correctly as follows:

    /**
     * Returns the percentage value position of the start of the thumb
     * relative to the draggable region
     */
    private fun getThumbStart(): Float {
        return (thumbStart) / getActualWidth()
    }

    /**
     * Returns the percentage value position of the end of the thumb
     * relative to the draggable region
     */
    private fun getThumbEnd(): Float {
        return (thumbEnd - (SIZE * 2)) / getActualWidth()
    }

Here's the final result for the complete SelectionView: Final screen

Pro-tip

The value of the end coordinates is expected to be equal to the width of the SelectionView, however the width of the view isn't available during intialization and therefore yields zero. When the measured width and height eventually becomes avaiable, android calls the onSizeChanged() method and delivers the dimensions as parameters, so this method can be overriden to set the end coordinate of the second thumb marker

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        this.thumbEnd = w.toFloat()
        this.h = h.toFloat()
        this.w = w.toFloat()
    }

2. Foreground Services and Work Manager

During the trimming operation on the video, I delegated this action to a foreground service that handles the execution of the process and displays the status via a notification. Since the previous release, the app worked as expected on devices I tested with. However, after some months, I recently discovered that the app crashes on Android 12+ devices. It was difficult to detect the root cause of this problem since the crashlytics report from firebase noted the crash, but it offered no reason as to why the crash happened nor any information on the model of devices experiencing this issue.

After a bit of digging into why this was happening, I was able to trace the cause to the foreground service. I came across this caveat from the documentation about background processes restricted from lauching foreground services on Android 12+. WorkManager is recommended as an alternative to this. It was hard to believe this could be the problem since the foreground service could have only be initialized after the user clicks a button on the app, and this means the app would have had to be running and displaying on screen.

I decided to give it a try just to see if it would resolve the error and surpisingly it did! The problem was the foreground service all along, and I'm still completely baffled about why the simple foreground service previously implemented crashed on higher android versions despite not violating the rules required to run one. And even if it did crash, why wasn't the ForegroundServiceStartNotAllowedException thrown and logged in the firebase crashlytics log reports? [I need answers

Anyway, I'm glad I revisited this project because I was able to improve it from its previous awful state🙉 into something much better and more efficient for use by every social media story spammer out there😁. If you don't already have the app, click here to get it from the App Store! The full source for the project is also publicly available on my Github profile. You can check it out and lemme know what you think!