Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Unverified Commit c5879957 authored by Sebastiano Barezzi's avatar Sebastiano Barezzi
Browse files

Recorder: Move from tasks to coroutines and flows



Query code stolen from Glimpse

Co-authored-by: default avatarLuca Stefani <luca.stefani.ge1@gmail.com>
Change-Id: Ibed443b7e0b0860a1a4511987c9a8b5cde14096a
parent 73e00e21
Loading
Loading
Loading
Loading
+6 −0
Original line number Diff line number Diff line
@@ -19,10 +19,16 @@ android_app {

    static_libs: [
        // DO NOT EDIT THIS SECTION MANUALLY
        "androidx.activity_activity-ktx",
        "androidx.appcompat_appcompat",
        "androidx.core_core-ktx",
        "Recorder_com.google.android.material_material",
        "androidx-constraintlayout_constraintlayout",
        "androidx.lifecycle_lifecycle-livedata-ktx",
        "androidx.lifecycle_lifecycle-service",
        "androidx.lifecycle_lifecycle-viewmodel-ktx",
        "androidx.recyclerview_recyclerview",
        "androidx.recyclerview_recyclerview-selection",
    ],

    sdk_version: "34",
+11 −1
Original line number Diff line number Diff line
@@ -71,10 +71,20 @@ dependencies {
    // Align versions of all Kotlin components
    implementation(platform("org.jetbrains.kotlin:kotlin-bom:1.8.0"))

    implementation("androidx.activity:activity-ktx:1.7.2")
    implementation("androidx.appcompat:appcompat:1.6.1")
    implementation("androidx.core:core-ktx:1.10.1")
    implementation("androidx.core:core-ktx:1.12.0")
    implementation("com.google.android.material:material:1.9.0")
    implementation("androidx.constraintlayout:constraintlayout:2.1.4")

    // Lifecycle
    implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.7.0")
    implementation("androidx.lifecycle:lifecycle-service:2.7.0")
    implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0")

    // Recyclerview
    implementation("androidx.recyclerview:recyclerview:1.3.2")
    implementation("androidx.recyclerview:recyclerview-selection:1.1.0")
}

configure<GenerateBpPluginExtension> {
+9 −11
Original line number Diff line number Diff line
@@ -8,22 +8,22 @@ package org.lineageos.recorder
import android.content.DialogInterface
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.viewModels
import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.lineageos.recorder.task.DeleteRecordingTask
import org.lineageos.recorder.task.TaskExecutor
import kotlinx.coroutines.launch
import org.lineageos.recorder.utils.PreferencesManager
import org.lineageos.recorder.utils.Utils
import org.lineageos.recorder.viewmodels.RecordingsViewModel

class DeleteLastActivity : ComponentActivity() {
    private val taskExecutor = TaskExecutor()
    // View models
    private val model: RecordingsViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setFinishOnTouchOutside(true)

        lifecycle.addObserver(taskExecutor)

        val preferences = PreferencesManager(this)
        val uri = preferences.lastItemUri ?: run {
            finish()
@@ -34,12 +34,10 @@ class DeleteLastActivity : ComponentActivity() {
            .setTitle(R.string.delete_title)
            .setMessage(getString(R.string.delete_recording_message))
            .setPositiveButton(R.string.delete) { d: DialogInterface, _: Int ->
                taskExecutor.runTask(
                    DeleteRecordingTask(contentResolver, uri)
                ) {
                    d.dismiss()
                    Utils.cancelShareNotification(this)
                lifecycleScope.launch {
                    model.deleteRecordings(uri)
                    preferences.lastItemUri = null
                    d.dismiss()
                }
            }
            .setNegativeButton(R.string.cancel, null)
+179 −141
Original line number Diff line number Diff line
@@ -6,7 +6,6 @@
package org.lineageos.recorder

import android.content.DialogInterface
import android.net.Uri
import android.os.Bundle
import android.view.ActionMode
import android.view.Menu
@@ -17,6 +16,7 @@ import android.view.inputmethod.InputMethodManager
import android.widget.EditText
import android.widget.ProgressBar
import android.widget.TextView
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
@@ -25,26 +25,30 @@ import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.Observer
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.selection.SelectionPredicates
import androidx.recyclerview.selection.SelectionTracker
import androidx.recyclerview.selection.StorageStrategy
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.lineageos.recorder.ext.scheduleShowSoftInput
import org.lineageos.recorder.list.ListActionModeCallback
import org.lineageos.recorder.list.RecordingData
import org.lineageos.recorder.list.RecordingListCallbacks
import org.lineageos.recorder.list.RecordingItemCallbacks
import org.lineageos.recorder.list.RecordingItemDetailsLookup
import org.lineageos.recorder.list.RecordingsAdapter
import org.lineageos.recorder.task.DeleteAllRecordingsTask
import org.lineageos.recorder.task.DeleteRecordingTask
import org.lineageos.recorder.task.GetRecordingsTask
import org.lineageos.recorder.task.RenameRecordingTask
import org.lineageos.recorder.task.TaskExecutor
import org.lineageos.recorder.models.Recording
import org.lineageos.recorder.utils.RecordIntentHelper
import org.lineageos.recorder.utils.Utils
import java.util.function.Consumer
import java.util.stream.Collectors
import org.lineageos.recorder.viewmodels.RecordingsViewModel

class ListActivity : AppCompatActivity() {
    // View models
    private val model: RecordingsViewModel by viewModels()

class ListActivity : AppCompatActivity(), RecordingListCallbacks {
    // Views
    private val contentView by lazy { findViewById<View>(android.R.id.content) }
    private val listEmptyTextView by lazy { findViewById<TextView>(R.id.listEmptyTextView) }
@@ -56,19 +60,110 @@ class ListActivity : AppCompatActivity(), RecordingListCallbacks {
    private val inputMethodManager by lazy { getSystemService(InputMethodManager::class.java) }

    // Adapters
    private val adapter by lazy {
        RecordingsAdapter(this)
    private val recordingItemCallbacks = object : RecordingItemCallbacks {
        override fun onPlay(recording: Recording) {
            this@ListActivity.onPlay(recording)
        }

        override fun onShare(recording: Recording) {
            this@ListActivity.onShare(recording)
        }

        override fun onDelete(recording: Recording) {
            this@ListActivity.onDelete(recording)
        }

        override fun onRename(recording: Recording) {
            this@ListActivity.onRename(recording)
        }
    }
    private val recordingsAdapter by lazy { RecordingsAdapter(model, recordingItemCallbacks) }

    // Selection
    private var selectionTracker: SelectionTracker<Recording>? = null

    private val selectionTrackerObserver =
        object : SelectionTracker.SelectionObserver<Recording>() {
            override fun onSelectionChanged() {
                super.onSelectionChanged()

                updateSelection()
            }

            override fun onSelectionRefresh() {
                super.onSelectionRefresh()

                updateSelection()
            }

            override fun onSelectionRestored() {
                super.onSelectionRestored()

                updateSelection()
            }
        }

    private var actionMode: ActionMode? = null

    private val taskExecutor = TaskExecutor()
    private val actionModeCallback = object : ActionMode.Callback {
        override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
            menuInflater.inflate(
                R.menu.menu_list_action_mode,
                menu
            )
            return true
        }

        override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?) = false

        override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?) =
            selectionTracker?.selection?.toList()?.toTypedArray()?.takeUnless {
                it.isEmpty()
            }?.let { selection ->
                when (item?.itemId) {
                    R.id.deleteForever -> {
                        MaterialAlertDialogBuilder(this@ListActivity)
                            .setTitle(R.string.delete_selected_title)
                            .setMessage(getString(R.string.delete_selected_message))
                            .setPositiveButton(R.string.delete) { _: DialogInterface?, _: Int ->
                                lifecycleScope.launch {
                                    model.deleteRecordings(*selection)
                                }
                            }
                            .setNegativeButton(R.string.cancel, null)
                            .show()

                        true
                    }

                    R.id.share -> {
                        val uris = selection.map { it.uri }

                        startActivity(RecordIntentHelper.getShareIntents(uris, TYPE_AUDIO))

                        true
                    }

                    else -> false
                }
            } ?: false

        override fun onDestroyActionMode(mode: ActionMode?) {
            selectionTracker?.clearSelection()
        }
    }

    private val inSelectionModeObserver = Observer { inSelectionMode: Boolean ->
        if (inSelectionMode) {
            startSelectionMode()
        } else {
            endSelectionMode()
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        lifecycle.addObserver(taskExecutor)

        setContentView(R.layout.activity_list)

        // Setup edge-to-edge
@@ -80,19 +175,8 @@ class ListActivity : AppCompatActivity(), RecordingListCallbacks {
            it.setDisplayHomeAsUpEnabled(true)
        }

        adapter.registerAdapterDataObserver(object : AdapterDataObserver() {
            override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) {
                super.onItemRangeRemoved(positionStart, itemCount)

                if (adapter.itemCount == 0) {
                    changeEmptyView(true)
                    endSelectionMode()
                }
            }
        })

        listRecyclerView.layoutManager = LinearLayoutManager(this)
        listRecyclerView.adapter = adapter
        listRecyclerView.adapter = recordingsAdapter

        ViewCompat.setOnApplyWindowInsetsListener(contentView) { _, windowInsets ->
            val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
@@ -106,42 +190,67 @@ class ListActivity : AppCompatActivity(), RecordingListCallbacks {
            windowInsets
        }

        loadRecordings()
        selectionTracker = SelectionTracker.Builder(
            "recordings",
            listRecyclerView,
            recordingsAdapter.itemKeyProvider,
            RecordingItemDetailsLookup(listRecyclerView),
            StorageStrategy.createParcelableStorage(Recording::class.java),
        ).withSelectionPredicate(
            SelectionPredicates.createSelectAnything()
        ).build().also {
            recordingsAdapter.selectionTracker = it
            it.addObserver(selectionTrackerObserver)
        }

    override fun onPlay(uri: Uri) {
        startActivity(RecordIntentHelper.getOpenIntent(uri, TYPE_AUDIO))
        model.inSelectionMode.observe(this, inSelectionModeObserver)

        lifecycleScope.launch {
            lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
                model.recordings.collectLatest {
                    recordingsAdapter.submitList(it)

                    listLoadingProgressBar.isVisible = false

                    val isEmpty = it.isEmpty()
                    changeEmptyView(isEmpty)
                    if (isEmpty) {
                        endSelectionMode()
                    }
                }
            }
        }
    }

    fun onPlay(recording: Recording) {
        startActivity(RecordIntentHelper.getOpenIntent(recording.uri, TYPE_AUDIO))
    }

    override fun onShare(uri: Uri) {
        startActivity(RecordIntentHelper.getShareIntent(uri, TYPE_AUDIO))
    fun onShare(recording: Recording) {
        startActivity(RecordIntentHelper.getShareIntent(recording.uri, TYPE_AUDIO))
    }

    override fun onDelete(index: Int, uri: Uri) {
    fun onDelete(recording: Recording) {
        MaterialAlertDialogBuilder(this)
            .setTitle(R.string.delete_title)
            .setMessage(R.string.delete_recording_message)
            .setPositiveButton(R.string.delete) { _: DialogInterface?, _: Int ->
                taskExecutor.runTask(
                    DeleteRecordingTask(contentResolver, uri)
                ) {
                    adapter.onDelete(index)
                    Utils.cancelShareNotification(this)
                lifecycleScope.launch {
                    model.deleteRecordings(recording)
                }
            }
            .setNegativeButton(R.string.cancel, null)
            .show()
    }

    override fun onRename(index: Int, uri: Uri, currentName: String) {
    fun onRename(recording: Recording) {
        lateinit var alertDialog: AlertDialog
        lateinit var editText: EditText

        val onConfirm = {
            editText.text?.takeIf { it.isNotEmpty() }?.let { editable ->
                val newTitle = editable.toString()
                if (newTitle != currentName) {
                    renameRecording(uri, newTitle, index)
                lifecycleScope.launch {
                    model.renameRecording(recording, editable.toString())
                }

                true
@@ -154,8 +263,8 @@ class ListActivity : AppCompatActivity(), RecordingListCallbacks {
            false
        )
        editText = view.findViewById<EditText>(R.id.nameEditText).apply {
            setText(currentName)
            setSelection(0, currentName.length)
            setText(recording.title)
            setSelection(0, recording.title.length)
            setOnEditorActionListener { _, actionId, _ ->
                when (actionId) {
                    EditorInfo.IME_ACTION_UNSPECIFIED,
@@ -193,7 +302,7 @@ class ListActivity : AppCompatActivity(), RecordingListCallbacks {

    override fun onPrepareOptionsMenu(menu: Menu): Boolean {
        val deleteAllItem = menu.findItem(R.id.action_delete_all)
        val hasItems = adapter.itemCount > 0
        val hasItems = recordingsAdapter.itemCount > 0
        deleteAllItem.setEnabled(hasItems)
        return true
    }
@@ -207,23 +316,27 @@ class ListActivity : AppCompatActivity(), RecordingListCallbacks {
        else -> false
    }

    override fun startSelectionMode() {
        // Clear previous (should do nothing), but be sure
        endSelectionMode()
        // Start action mode
        actionMode = toolbar.startActionMode(
            ListActionModeCallback({
                deleteSelectedRecordings()
            }) { shareSelectedRecordings() }
    private fun updateSelection() {
        model.inSelectionMode.value = selectionTracker?.hasSelection() == true

        selectionTracker?.selection?.count()?.takeIf { it > 0 }?.let {
            startSelectionMode().apply {
                title = resources.getQuantityString(
                    R.plurals.recording_selection_count, it, it
                )
        adapter.enterSelectionMode()
            }
        }
    }

    private fun startSelectionMode() = actionMode ?: toolbar.startActionMode(
        actionModeCallback
    ).also {
        actionMode = it
    }

    override fun endSelectionMode() {
    private fun endSelectionMode() {
        actionMode?.finish()
        actionMode = null

        adapter.exitSelectionMode()
    }

    override fun onActionModeFinished(mode: ActionMode) {
@@ -231,89 +344,13 @@ class ListActivity : AppCompatActivity(), RecordingListCallbacks {
        endSelectionMode()
    }

    private fun loadRecordings() {
        taskExecutor.runTask(
            GetRecordingsTask(
                applicationContext.packageName,
                contentResolver
            )
        ) { list: List<RecordingData> ->
            listLoadingProgressBar.isVisible = false
            adapter.data = list
            changeEmptyView(list.isEmpty())
        }
    }

    private fun renameRecording(uri: Uri, newTitle: String, index: Int) {
        taskExecutor.runTask(
            RenameRecordingTask(contentResolver, uri, newTitle)
        ) { success: Boolean ->
            if (success) {
                adapter.onRename(index, newTitle)
            }
        }
    }

    private fun deleteRecording(item: RecordingData) {
        taskExecutor.runTask(
            DeleteRecordingTask(contentResolver, item.uri)
        ) {
            adapter.onDelete(item)
            Utils.cancelShareNotification(this)
        }
    }

    private fun deleteAllRecordings() {
        val uris = adapter.data.stream()
            .map { obj: RecordingData -> obj.uri }
            .collect(Collectors.toList())
        taskExecutor.runTask(DeleteAllRecordingsTask(contentResolver, uris)) {
            adapter.data = emptyList()
            changeEmptyView(true)
        }
    }

    private fun changeEmptyView(isEmpty: Boolean) {
        listEmptyTextView.isVisible = isEmpty
        listRecyclerView.isVisible = !isEmpty
    }

    private fun shareSelectedRecordings() {
        val selectedItems = adapter.selected

        if (selectedItems.isEmpty()) {
            return
        }

        val uris = selectedItems.stream()
            .map { obj: RecordingData -> obj.uri }
            .collect(
                Collectors.toCollection { mutableListOf() }
            )

        startActivity(RecordIntentHelper.getShareIntents(uris, TYPE_AUDIO))
    }

    private fun deleteSelectedRecordings() {
        val selectedItems = adapter.selected

        if (selectedItems.isEmpty()) {
            return
        }

        MaterialAlertDialogBuilder(this)
            .setTitle(R.string.delete_selected_title)
            .setMessage(getString(R.string.delete_selected_message))
            .setPositiveButton(R.string.delete) { _: DialogInterface?, _: Int ->
                selectedItems.forEach(Consumer { item: RecordingData -> deleteRecording(item) })
                Utils.cancelShareNotification(this)
            }
            .setNegativeButton(R.string.cancel, null)
            .show()
    }

    private fun promptDeleteAllRecordings() {
        if (adapter.itemCount == 0) {
        if (recordingsAdapter.itemCount == 0) {
            return
        }

@@ -321,8 +358,9 @@ class ListActivity : AppCompatActivity(), RecordingListCallbacks {
            .setTitle(R.string.delete_all_title)
            .setMessage(getString(R.string.delete_all_message))
            .setPositiveButton(R.string.delete) { _: DialogInterface?, _: Int ->
                deleteAllRecordings()
                Utils.cancelShareNotification(this)
                lifecycleScope.launch {
                    model.deleteRecordings(*recordingsAdapter.currentList.toTypedArray())
                }
            }
            .setNegativeButton(R.string.cancel, null)
            .show()
+10 −8
Original line number Diff line number Diff line
@@ -27,6 +27,7 @@ import android.text.format.DateUtils
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
@@ -34,18 +35,19 @@ import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.floatingactionbutton.FloatingActionButton
import kotlinx.coroutines.launch
import org.lineageos.recorder.models.UiStatus
import org.lineageos.recorder.service.SoundRecorderService
import org.lineageos.recorder.status.UiStatus
import org.lineageos.recorder.task.DeleteRecordingTask
import org.lineageos.recorder.task.TaskExecutor
import org.lineageos.recorder.ui.WaveFormView
import org.lineageos.recorder.utils.LocationHelper
import org.lineageos.recorder.utils.OnBoardingHelper
import org.lineageos.recorder.utils.PermissionManager
import org.lineageos.recorder.utils.PreferencesManager
import org.lineageos.recorder.utils.Utils
import org.lineageos.recorder.viewmodels.RecordingsViewModel
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeFormatterBuilder
@@ -54,6 +56,9 @@ import java.util.Locale
import kotlin.reflect.safeCast

class RecorderActivity : AppCompatActivity(R.layout.activity_main) {
    // View models
    private val model: RecordingsViewModel by viewModels()

    // Views
    private val contentView by lazy { findViewById<View>(android.R.id.content) }
    private val elapsedTimeText by lazy { findViewById<TextView>(R.id.elapsedTimeTextView) }
@@ -67,7 +72,6 @@ class RecorderActivity : AppCompatActivity(R.layout.activity_main) {
    private val locationHelper by lazy { LocationHelper(this) }
    private val permissionManager by lazy { PermissionManager(this) }
    private val preferencesManager by lazy { PreferencesManager(this) }
    private val taskExecutor = TaskExecutor()

    private var returnAudio = false
    private var hasRecordedAudio = false
@@ -143,8 +147,6 @@ class RecorderActivity : AppCompatActivity(R.layout.activity_main) {
            windowInsets
        }

        lifecycle.addObserver(taskExecutor)

        if (MediaStore.Audio.Media.RECORD_SOUND_ACTION == intent.action) {
            returnAudio = true
            openSoundListImageView.isVisible = false
@@ -335,8 +337,8 @@ class RecorderActivity : AppCompatActivity(R.layout.activity_main) {

    private fun discardLastResult() {
        preferencesManager.lastItemUri?.let {
            taskExecutor.runTask(DeleteRecordingTask(contentResolver, it)) {
                Utils.cancelShareNotification(this)
            lifecycleScope.launch {
                model.deleteRecordings(it)
                preferencesManager.lastItemUri = null
            }
        }
Loading