diff --git a/app/Android.bp b/app/Android.bp index f1331b85..4f6aca3f 100644 --- a/app/Android.bp +++ b/app/Android.bp @@ -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", diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5908d574..508b1895 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 { diff --git a/app/src/main/java/org/lineageos/recorder/DeleteLastActivity.kt b/app/src/main/java/org/lineageos/recorder/DeleteLastActivity.kt index efcab6a1..4a26fad9 100644 --- a/app/src/main/java/org/lineageos/recorder/DeleteLastActivity.kt +++ b/app/src/main/java/org/lineageos/recorder/DeleteLastActivity.kt @@ -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) diff --git a/app/src/main/java/org/lineageos/recorder/ListActivity.kt b/app/src/main/java/org/lineageos/recorder/ListActivity.kt index a5df0dc9..71f1921a 100644 --- a/app/src/main/java/org/lineageos/recorder/ListActivity.kt +++ b/app/src/main/java/org/lineageos/recorder/ListActivity.kt @@ -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(android.R.id.content) } private val listEmptyTextView by lazy { findViewById(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? = null + + private val selectionTrackerObserver = + object : SelectionTracker.SelectionObserver() { + 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) + } + + 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() + } + } + } + } } - override fun onPlay(uri: Uri) { - startActivity(RecordIntentHelper.getOpenIntent(uri, TYPE_AUDIO)) + 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(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() } - ) - adapter.enterSelectionMode() + 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 + ) + } + } + } + + 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 -> - 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() diff --git a/app/src/main/java/org/lineageos/recorder/RecorderActivity.kt b/app/src/main/java/org/lineageos/recorder/RecorderActivity.kt index d89ec9c9..2091af89 100644 --- a/app/src/main/java/org/lineageos/recorder/RecorderActivity.kt +++ b/app/src/main/java/org/lineageos/recorder/RecorderActivity.kt @@ -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(android.R.id.content) } private val elapsedTimeText by lazy { findViewById(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 } } diff --git a/app/src/main/java/org/lineageos/recorder/ext/AndroidViewModel.kt b/app/src/main/java/org/lineageos/recorder/ext/AndroidViewModel.kt new file mode 100644 index 00000000..e180d9fe --- /dev/null +++ b/app/src/main/java/org/lineageos/recorder/ext/AndroidViewModel.kt @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: 2023 The LineageOS Project + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.lineageos.recorder.ext + +import android.app.Application +import android.content.Context +import androidx.lifecycle.AndroidViewModel + +val AndroidViewModel.applicationContext: Context + get() = getApplication().applicationContext diff --git a/app/src/main/java/org/lineageos/recorder/ext/ContentResolver.kt b/app/src/main/java/org/lineageos/recorder/ext/ContentResolver.kt new file mode 100644 index 00000000..ad93ae70 --- /dev/null +++ b/app/src/main/java/org/lineageos/recorder/ext/ContentResolver.kt @@ -0,0 +1,68 @@ +/* + * SPDX-FileCopyrightText: 2023 The LineageOS Project + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.lineageos.recorder.ext + +import android.content.ContentResolver +import android.database.ContentObserver +import android.net.Uri +import android.os.Bundle +import android.os.CancellationSignal +import android.os.Handler +import android.os.Looper +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +fun ContentResolver.queryFlow( + uri: Uri, + projection: Array? = null, + queryArgs: Bundle? = Bundle(), +) = callbackFlow { + // Each query will have its own cancellationSignal. + // Before running any new query the old cancellationSignal must be cancelled + // to ensure the currently running query gets interrupted so that we don't + // send data across the channel if we know we received a newer set of data. + var cancellationSignal = CancellationSignal() + // ContentObserver.onChange can be called concurrently so make sure + // access to the cancellationSignal is synchronized. + val mutex = Mutex() + + val observer = object : ContentObserver(Handler(Looper.getMainLooper())) { + override fun onChange(selfChange: Boolean) { + launch(Dispatchers.IO) { + mutex.withLock { + cancellationSignal.cancel() + cancellationSignal = CancellationSignal() + } + runCatching { + trySend(query(uri, projection, queryArgs, cancellationSignal)) + } + } + } + } + + registerContentObserver(uri, true, observer) + + // The first set of values must always be generated and cannot (shouldn't) be cancelled. + launch(Dispatchers.IO) { + runCatching { + trySend( + query(uri, projection, queryArgs, null) + ) + } + } + + awaitClose { + // Stop receiving content changes. + unregisterContentObserver(observer) + // Cancel any possibly running query. + cancellationSignal.cancel() + } +}.conflate() diff --git a/app/src/main/java/org/lineageos/recorder/ext/Cursor.kt b/app/src/main/java/org/lineageos/recorder/ext/Cursor.kt new file mode 100644 index 00000000..fbf34bd9 --- /dev/null +++ b/app/src/main/java/org/lineageos/recorder/ext/Cursor.kt @@ -0,0 +1,28 @@ +/* + * SPDX-FileCopyrightText: 2023 The LineageOS Project + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.lineageos.recorder.ext + +import android.database.Cursor + +fun Cursor?.mapEachRow( + projection: Array? = null, + mapping: (Cursor, Array) -> T, +) = this?.use { cursor -> + if (!cursor.moveToFirst()) { + return@use emptyList() + } + + val indexCache = projection?.map { column -> + cursor.getColumnIndexOrThrow(column) + }?.toTypedArray() ?: arrayOf() + + val data = mutableListOf() + do { + data.add(mapping(cursor, indexCache)) + } while (cursor.moveToNext()) + + data.toList() +} ?: emptyList() diff --git a/app/src/main/java/org/lineageos/recorder/ext/Flow.kt b/app/src/main/java/org/lineageos/recorder/ext/Flow.kt new file mode 100644 index 00000000..f380b80d --- /dev/null +++ b/app/src/main/java/org/lineageos/recorder/ext/Flow.kt @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: 2023 The LineageOS Project + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.lineageos.recorder.ext + +import android.database.Cursor +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +fun Flow.mapEachRow( + projection: Array, + mapping: (Cursor, Array) -> T, +) = map { it.mapEachRow(projection, mapping) } diff --git a/app/src/main/java/org/lineageos/recorder/ext/Parcelable.kt b/app/src/main/java/org/lineageos/recorder/ext/Parcelable.kt new file mode 100644 index 00000000..7407f900 --- /dev/null +++ b/app/src/main/java/org/lineageos/recorder/ext/Parcelable.kt @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2023-2024 The LineageOS Project + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.lineageos.recorder.ext + +import android.os.Build +import android.os.Parcel +import android.os.Parcelable +import kotlin.reflect.KClass + +fun Parcel.readParcelable(clazz: KClass) = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + readParcelable(clazz.java.classLoader, clazz.java) + } else { + @Suppress("DEPRECATION") + readParcelable(clazz.java.classLoader) + } diff --git a/app/src/main/java/org/lineageos/recorder/ext/SelectionTracker.kt b/app/src/main/java/org/lineageos/recorder/ext/SelectionTracker.kt new file mode 100644 index 00000000..7b6e6408 --- /dev/null +++ b/app/src/main/java/org/lineageos/recorder/ext/SelectionTracker.kt @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: 2024 The LineageOS Project + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.lineageos.recorder.ext + +import androidx.recyclerview.selection.MutableSelection +import androidx.recyclerview.selection.SelectionTracker + +/** + * Get the current election. + * @see SelectionTracker.getSelection + * @see SelectionTracker.copySelection + */ +fun SelectionTracker.getCurrentSelection() = MutableSelection().apply { + copySelection(this) +} diff --git a/app/src/main/java/org/lineageos/recorder/flow/QueryFlow.kt b/app/src/main/java/org/lineageos/recorder/flow/QueryFlow.kt new file mode 100644 index 00000000..23c8bda1 --- /dev/null +++ b/app/src/main/java/org/lineageos/recorder/flow/QueryFlow.kt @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2023 The LineageOS Project + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.lineageos.recorder.flow + +import android.database.Cursor +import kotlinx.coroutines.flow.Flow + +abstract class QueryFlow { + /** A flow of the data specified by the query */ + abstract fun flowData(): Flow> + + /** A flow of the cursor specified by the query */ + abstract fun flowCursor(): Flow +} diff --git a/app/src/main/java/org/lineageos/recorder/flow/RecordingsFlow.kt b/app/src/main/java/org/lineageos/recorder/flow/RecordingsFlow.kt new file mode 100644 index 00000000..9baa5338 --- /dev/null +++ b/app/src/main/java/org/lineageos/recorder/flow/RecordingsFlow.kt @@ -0,0 +1,71 @@ +/* + * SPDX-FileCopyrightText: 2024 The LineageOS Project + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.lineageos.recorder.flow + +import android.content.ContentResolver +import android.content.ContentUris +import android.content.Context +import android.database.Cursor +import android.provider.MediaStore +import androidx.core.os.bundleOf +import kotlinx.coroutines.flow.Flow +import org.lineageos.recorder.ext.mapEachRow +import org.lineageos.recorder.ext.queryFlow +import org.lineageos.recorder.models.Recording +import org.lineageos.recorder.query.Query +import org.lineageos.recorder.query.eq + +class RecordingsFlow( + val context: Context, +) : QueryFlow() { + override fun flowCursor(): Flow { + val uri = MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL) + val selection = MediaStore.Audio.Media.OWNER_PACKAGE_NAME eq Query.ARG + val selectionArgs = arrayOf( + context.packageName, + ) + val sortOrder = "${MediaStore.Audio.Media.DATE_ADDED} DESC" + + return context.contentResolver.queryFlow( + uri, + projection, + bundleOf( + ContentResolver.QUERY_ARG_SQL_SELECTION to selection.build(), + ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS to selectionArgs, + ContentResolver.QUERY_ARG_SQL_SORT_ORDER to sortOrder, + ), + ) + } + + override fun flowData() = flowCursor().mapEachRow(projection) { it, indexCache -> + var i = 0 + + val id = it.getLong(indexCache[i++]) + val displayName = it.getString(indexCache[i++]) + val dateAdded = it.getLong(indexCache[i++]) + val duration = it.getLong(indexCache[i++]) + + val uri = ContentUris.withAppendedId( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, id + ) + + Recording.fromMediaStore( + uri, + displayName, + dateAdded, + duration, + ) + } + + companion object { + private val projection = arrayOf( + MediaStore.Audio.Media._ID, + MediaStore.Audio.Media.DISPLAY_NAME, + MediaStore.Audio.Media.DATE_ADDED, + MediaStore.Audio.Media.DURATION + ) + } +} diff --git a/app/src/main/java/org/lineageos/recorder/list/ListActionModeCallback.kt b/app/src/main/java/org/lineageos/recorder/list/ListActionModeCallback.kt deleted file mode 100644 index 3cd18a96..00000000 --- a/app/src/main/java/org/lineageos/recorder/list/ListActionModeCallback.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021-2024 The LineageOS Project - * SPDX-License-Identifier: Apache-2.0 - */ - -package org.lineageos.recorder.list - -import android.view.ActionMode -import android.view.Menu -import android.view.MenuItem -import org.lineageos.recorder.R - -class ListActionModeCallback( - private val deleteSelected: Runnable, - private val shareSelected: Runnable -) : ActionMode.Callback { - override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { - val inflater = mode.menuInflater - inflater.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) = when (item.itemId) { - R.id.action_delete_selected -> { - deleteSelected.run() - true - } - - R.id.action_share_selected -> { - shareSelected.run() - true - } - - else -> false - } - - override fun onDestroyActionMode(mode: ActionMode) {} -} diff --git a/app/src/main/java/org/lineageos/recorder/list/ListItemStatus.kt b/app/src/main/java/org/lineageos/recorder/list/ListItemStatus.kt deleted file mode 100644 index e974ac81..00000000 --- a/app/src/main/java/org/lineageos/recorder/list/ListItemStatus.kt +++ /dev/null @@ -1,12 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021-2024 The LineageOS Project - * SPDX-License-Identifier: Apache-2.0 - */ - -package org.lineageos.recorder.list - -enum class ListItemStatus { - DEFAULT, - UNCHECKED, - CHECKED, -} diff --git a/app/src/main/java/org/lineageos/recorder/list/RecordingData.kt b/app/src/main/java/org/lineageos/recorder/list/RecordingData.kt deleted file mode 100644 index b6624eb6..00000000 --- a/app/src/main/java/org/lineageos/recorder/list/RecordingData.kt +++ /dev/null @@ -1,24 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021-2024 The LineageOS Project - * SPDX-License-Identifier: Apache-2.0 - */ - -package org.lineageos.recorder.list - -import android.net.Uri -import java.time.LocalDateTime -import java.util.Objects -import kotlin.reflect.safeCast - -class RecordingData( - val uri: Uri, - val title: String, - val dateTime: LocalDateTime, - val duration: Long -) { - override fun equals(other: Any?) = RecordingData::class.safeCast(other)?.let { - duration == it.duration && uri == it.uri && title == it.title && dateTime == it.dateTime - } ?: false - - override fun hashCode() = Objects.hash(uri, title, dateTime, duration) -} diff --git a/app/src/main/java/org/lineageos/recorder/list/RecordingItemCallbacks.kt b/app/src/main/java/org/lineageos/recorder/list/RecordingItemCallbacks.kt index dcbebafc..e4f8e455 100644 --- a/app/src/main/java/org/lineageos/recorder/list/RecordingItemCallbacks.kt +++ b/app/src/main/java/org/lineageos/recorder/list/RecordingItemCallbacks.kt @@ -5,11 +5,11 @@ package org.lineageos.recorder.list -import android.net.Uri +import org.lineageos.recorder.models.Recording interface RecordingItemCallbacks { - fun onPlay(uri: Uri) - fun onShare(uri: Uri) - fun onDelete(index: Int, uri: Uri) - fun onRename(index: Int, uri: Uri, currentName: String) + fun onPlay(recording: Recording) + fun onShare(recording: Recording) + fun onDelete(recording: Recording) + fun onRename(recording: Recording) } diff --git a/app/src/main/java/org/lineageos/recorder/list/RecordingItemDetailsLookup.kt b/app/src/main/java/org/lineageos/recorder/list/RecordingItemDetailsLookup.kt new file mode 100644 index 00000000..10c66803 --- /dev/null +++ b/app/src/main/java/org/lineageos/recorder/list/RecordingItemDetailsLookup.kt @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: 2024 The LineageOS Project + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.lineageos.recorder.list + +import android.view.MotionEvent +import androidx.recyclerview.selection.ItemDetailsLookup +import androidx.recyclerview.widget.RecyclerView +import org.lineageos.recorder.models.Recording +import kotlin.reflect.safeCast + +class RecordingItemDetailsLookup( + private val recyclerView: RecyclerView, +) : ItemDetailsLookup() { + override fun getItemDetails(e: MotionEvent) = + recyclerView.findChildViewUnder(e.x, e.y)?.let { childView -> + recyclerView.getChildViewHolder(childView)?.let { viewHolder -> + RecordingsAdapter.ViewHolder::class.safeCast(viewHolder)?.itemDetails + } + } +} diff --git a/app/src/main/java/org/lineageos/recorder/list/RecordingItemViewHolder.kt b/app/src/main/java/org/lineageos/recorder/list/RecordingItemViewHolder.kt deleted file mode 100644 index a6c9a75d..00000000 --- a/app/src/main/java/org/lineageos/recorder/list/RecordingItemViewHolder.kt +++ /dev/null @@ -1,104 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021-2024 The LineageOS Project - * SPDX-License-Identifier: Apache-2.0 - */ - -package org.lineageos.recorder.list - -import android.annotation.SuppressLint -import android.net.Uri -import android.text.format.DateUtils -import android.view.ContextThemeWrapper -import android.view.Gravity -import android.view.MenuItem -import android.view.View -import android.widget.ImageView -import android.widget.TextView -import androidx.appcompat.view.menu.MenuBuilder -import androidx.appcompat.view.menu.MenuPopupHelper -import androidx.appcompat.widget.PopupMenu -import androidx.recyclerview.widget.RecyclerView -import org.lineageos.recorder.R -import java.time.format.DateTimeFormatter -import java.util.Locale - -class RecordingItemViewHolder( - itemView: View, - private val callbacks: RecordingItemCallbacks, - private val dateFormat: DateTimeFormatter -) : RecyclerView.ViewHolder(itemView) { - // Views - private val dateTextView by lazy { itemView.findViewById(R.id.dateTextView) } - private val menuImageView by lazy { itemView.findViewById(R.id.menuImageView) } - private val playImageView by lazy { itemView.findViewById(R.id.playImageView) } - private val titleTextView by lazy { itemView.findViewById(R.id.titleTextView) } - - var uri: Uri? = null - private set - - init { - menuImageView.setOnClickListener { showPopupMenu(it) } - } - - fun setData(data: RecordingData, selection: ListItemStatus) { - uri = data.uri - titleTextView.text = data.title - val duration = data.duration / 1000 - dateTextView.text = String.format( - Locale.getDefault(), SUMMARY_FORMAT, - dateFormat.format(data.dateTime), - DateUtils.formatElapsedTime(duration) - ) - - playImageView.setImageResource( - when (selection) { - ListItemStatus.DEFAULT -> R.drawable.ic_play_circle - ListItemStatus.UNCHECKED -> R.drawable.ic_radio_button_unchecked - ListItemStatus.CHECKED -> R.drawable.ic_check_circle - } - ) - } - - @SuppressLint("RestrictedApi") - private fun showPopupMenu(view: View) { - val wrapper = ContextThemeWrapper( - itemView.context, - R.style.AppTheme_PopupMenuOverlapAnchor - ) - val popupMenu = PopupMenu(wrapper, view, Gravity.NO_GRAVITY) - popupMenu.inflate(R.menu.menu_list_item) - popupMenu.setOnMenuItemClickListener { item: MenuItem -> onActionSelected(item.itemId) } - val helper = MenuPopupHelper( - wrapper, - (popupMenu.menu as MenuBuilder), - view - ) - helper.setForceShowIcon(true) - helper.show() - } - - private fun onActionSelected(actionId: Int) = uri?.let { - when (actionId) { - R.id.action_rename -> { - callbacks.onRename(adapterPosition, it, titleTextView.text.toString()) - true - } - - R.id.action_share -> { - callbacks.onShare(it) - true - } - - R.id.action_delete -> { - callbacks.onDelete(adapterPosition, it) - true - } - - else -> false - } - } ?: false - - companion object { - private const val SUMMARY_FORMAT = "%s - %s" - } -} diff --git a/app/src/main/java/org/lineageos/recorder/list/RecordingListCallbacks.kt b/app/src/main/java/org/lineageos/recorder/list/RecordingListCallbacks.kt deleted file mode 100644 index b5d4b8ae..00000000 --- a/app/src/main/java/org/lineageos/recorder/list/RecordingListCallbacks.kt +++ /dev/null @@ -1,11 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021-2024 The LineageOS Project - * SPDX-License-Identifier: Apache-2.0 - */ - -package org.lineageos.recorder.list - -interface RecordingListCallbacks : RecordingItemCallbacks { - fun startSelectionMode() - fun endSelectionMode() -} diff --git a/app/src/main/java/org/lineageos/recorder/list/RecordingsAdapter.kt b/app/src/main/java/org/lineageos/recorder/list/RecordingsAdapter.kt index 9ffe708a..214cb734 100644 --- a/app/src/main/java/org/lineageos/recorder/list/RecordingsAdapter.kt +++ b/app/src/main/java/org/lineageos/recorder/list/RecordingsAdapter.kt @@ -5,155 +5,210 @@ package org.lineageos.recorder.list +import android.view.ContextThemeWrapper +import android.view.Gravity import android.view.LayoutInflater +import android.view.MenuItem +import android.view.View import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.appcompat.view.menu.MenuBuilder +import androidx.appcompat.view.menu.MenuPopupHelper +import androidx.appcompat.widget.PopupMenu +import androidx.lifecycle.Observer +import androidx.lifecycle.findViewTreeLifecycleOwner +import androidx.recyclerview.selection.ItemDetailsLookup +import androidx.recyclerview.selection.ItemKeyProvider +import androidx.recyclerview.selection.SelectionTracker import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import org.lineageos.recorder.R -import java.time.format.DateTimeFormatter +import org.lineageos.recorder.models.Recording +import org.lineageos.recorder.viewmodels.RecordingsViewModel +import java.text.SimpleDateFormat import java.util.Locale -class RecordingsAdapter(private val callbacks: RecordingListCallbacks) : - RecyclerView.Adapter() { - private val dateFormat = DateTimeFormatter.ofPattern( - "yyyy-MM-dd HH:mm", Locale.getDefault() +class RecordingsAdapter( + private val model: RecordingsViewModel, + private val callbacks: RecordingItemCallbacks, +) : ListAdapter(diffCallback) { + // We store a reverse lookup list for performance reasons + private var recordingToIndex: Map? = null + + var selectionTracker: SelectionTracker? = null + + val itemKeyProvider = object : ItemKeyProvider(SCOPE_CACHED) { + override fun getKey(position: Int) = getItem(position) + + override fun getPosition(key: Recording) = recordingToIndex?.get(key) ?: -1 + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder( + LayoutInflater.from(parent.context).inflate(R.layout.list_item, parent, false), ) - private var _data = mutableListOf() - private var _selected = mutableListOf() - private var inSelectionMode = false - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecordingItemViewHolder { - val view = LayoutInflater.from(parent.context) - .inflate(R.layout.list_item, parent, false) - val viewHolder = RecordingItemViewHolder( - view, - callbacks, dateFormat - ) - viewHolder.itemView.setOnClickListener { - if (inSelectionMode) { - changeSelectedState(viewHolder.adapterPosition) - } else { - viewHolder.uri?.let { - callbacks.onPlay(it) - } - } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val recording = getItem(position) + + val selectionStatus = selectionTracker?.isSelected(recording) == true + + holder.bind(recording, selectionStatus) + } + + override fun onCurrentListChanged( + previousList: MutableList, + currentList: MutableList + ) { + super.onCurrentListChanged(previousList, currentList) + + // This gets randomly called with null as argument + if (currentList == null) { + return } - viewHolder.itemView.setOnLongClickListener { - changeSelectedState(viewHolder.adapterPosition) - true + + val dataTypeToIndex = mutableMapOf() + for (i in currentList.indices) { + dataTypeToIndex[currentList[i]] = i } - return viewHolder + this.recordingToIndex = dataTypeToIndex.toMap() } - override fun onBindViewHolder(holder: RecordingItemViewHolder, position: Int) { - val item = _data[position] - val selectionStatus = - if (_selected.isEmpty()) ListItemStatus.DEFAULT else if (_selected.contains( - item - ) - ) ListItemStatus.CHECKED else ListItemStatus.UNCHECKED - holder.setData(item, selectionStatus) - } + override fun onViewAttachedToWindow(holder: ViewHolder) { + super.onViewAttachedToWindow(holder) - override fun getItemCount(): Int { - return _data.size + holder.onViewAttachedToWindow() } - var data: List - get() = _data - set(data) { - _selected = mutableListOf() - inSelectionMode = false - if (data.size < DIFF_MAX_SIZE) { - val diff = DiffUtil.calculateDiff(DiffCallback(_data, data)) - _data = data.toMutableList() - diff.dispatchUpdatesTo(this) - } else { - _data = data.toMutableList() - notifyItemRangeChanged(0, data.size) - } - } + override fun onViewDetachedFromWindow(holder: ViewHolder) { + super.onViewDetachedFromWindow(holder) - fun onDelete(index: Int) { - _selected.remove(_data.removeAt(index)) - notifyItemRemoved(index) + holder.onViewDetachedToWindow() } - fun onDelete(item: RecordingData) { - val index = _data.indexOf(item) - if (index >= 0) { - onDelete(index) - } - } + inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + // Views + private val dateTextView by lazy { itemView.findViewById(R.id.dateTextView) } + private val menuImageView by lazy { itemView.findViewById(R.id.menuImageView) } + private val playImageView by lazy { itemView.findViewById(R.id.playImageView) } + private val titleTextView by lazy { itemView.findViewById(R.id.titleTextView) } + + private lateinit var recording: Recording - fun onRename(index: Int, newTitle: String) { - val oldData = _data[index] - val newData = RecordingData( - oldData.uri, newTitle, oldData.dateTime, - oldData.duration - ) - _data[index] = newData - val selectIndex = _selected.indexOf(oldData) - if (selectIndex >= 0) { - _selected[selectIndex] = newData + // Selection + private var inSelectionMode = false + private var isSelected = false + + private val inSelectionModeObserver = Observer { inSelectionMode: Boolean -> + this.inSelectionMode = inSelectionMode + + updateSelection() } - notifyItemChanged(index) - } - val selected: List - get() = ArrayList(_selected) + val itemDetails = object : ItemDetailsLookup.ItemDetails() { + override fun getPosition() = bindingAdapterPosition + override fun getSelectionKey() = recording + } - private fun changeSelectedState(position: Int) { - if (!inSelectionMode) { - callbacks.startSelectionMode() + init { + itemView.setOnClickListener { callbacks.onPlay(recording) } + menuImageView.setOnClickListener { showPopupMenu(it) } } - val item = _data[position] - if (_selected.contains(item)) { - _selected.remove(item) - } else { - _selected.add(_data[position]) + + fun onViewAttachedToWindow() { + itemView.findViewTreeLifecycleOwner()?.let { + model.inSelectionMode.observe(it, inSelectionModeObserver) + } } - notifyItemChanged(position) - if (_selected.isEmpty()) { - callbacks.endSelectionMode() + + fun onViewDetachedToWindow() { + model.inSelectionMode.removeObserver(inSelectionModeObserver) } - } - fun enterSelectionMode() { - inSelectionMode = true - notifyItemRangeChanged(0, _data.size) - } + fun bind(recording: Recording, isSelected: Boolean = false) { + this.recording = recording + this.isSelected = isSelected - fun exitSelectionMode() { - _selected.clear() - inSelectionMode = false - notifyItemRangeChanged(0, _data.size) - } + titleTextView.text = recording.title + dateTextView.text = String.format( + Locale.getDefault(), SUMMARY_FORMAT, + dateFormatter.format(recording.dateAdded), + timeFormatter.format(recording.dateAdded) + ) - private class DiffCallback( - private val oldList: List, - private val newList: List - ) : DiffUtil.Callback() { - override fun getOldListSize(): Int { - return oldList.size + updateSelection() } - override fun getNewListSize(): Int { - return newList.size + private fun updateSelection( + inSelectionMode: Boolean = this.inSelectionMode, + isSelected: Boolean = this.isSelected, + ) { + playImageView.setImageResource( + when (inSelectionMode) { + false -> R.drawable.ic_play_circle + true -> when (isSelected) { + false -> R.drawable.ic_radio_button_unchecked + true -> R.drawable.ic_check_circle + } + } + ) } - override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { - return (oldList[oldItemPosition].uri - == newList[newItemPosition].uri) + @Suppress("RestrictedApi") + private fun showPopupMenu(view: View) { + val wrapper = ContextThemeWrapper( + itemView.context, + R.style.AppTheme_PopupMenuOverlapAnchor + ) + val popupMenu = PopupMenu(wrapper, view, Gravity.NO_GRAVITY) + popupMenu.inflate(R.menu.menu_list_item) + popupMenu.setOnMenuItemClickListener { item: MenuItem -> onActionSelected(item.itemId) } + val helper = MenuPopupHelper( + wrapper, + (popupMenu.menu as MenuBuilder), + view + ) + helper.setForceShowIcon(true) + helper.show() } - override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { - return oldList[oldItemPosition] == newList[newItemPosition] + private fun onActionSelected(actionId: Int) = when (actionId) { + R.id.action_rename -> { + callbacks.onRename(recording) + true + } + + R.id.action_share -> { + callbacks.onShare(recording) + true + } + + R.id.action_delete -> { + callbacks.onDelete(recording) + true + } + + else -> false } } companion object { - // According to documentation, DiffUtil is not working with lists of size > 2^26 - private const val DIFF_MAX_SIZE = 1 shl 26 + private val diffCallback = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: Recording, + newItem: Recording + ) = oldItem.uri == newItem.uri + + override fun areContentsTheSame( + oldItem: Recording, + newItem: Recording + ) = oldItem == newItem + } + + private const val SUMMARY_FORMAT = "%s - %s" + + private val dateFormatter = SimpleDateFormat.getDateInstance() + private val timeFormatter = SimpleDateFormat.getTimeInstance() } } diff --git a/app/src/main/java/org/lineageos/recorder/models/Recording.kt b/app/src/main/java/org/lineageos/recorder/models/Recording.kt new file mode 100644 index 00000000..1c0bd8b8 --- /dev/null +++ b/app/src/main/java/org/lineageos/recorder/models/Recording.kt @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: 2021-2024 The LineageOS Project + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.lineageos.recorder.models + +import android.net.Uri +import android.os.Parcel +import android.os.Parcelable +import org.lineageos.recorder.ext.readParcelable +import java.util.Date + +data class Recording( + val uri: Uri, + val title: String, + val dateAdded: Date, + val duration: Long, +) : Parcelable { + constructor(parcel: Parcel) : this( + parcel.readParcelable(Uri::class)!!, + parcel.readString()!!, + Date(parcel.readLong()), + parcel.readLong() + ) + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeParcelable(uri, flags) + parcel.writeString(title) + parcel.writeLong(dateAdded.time) + parcel.writeLong(duration) + } + + override fun describeContents() = 0 + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel) = Recording(parcel) + + override fun newArray(size: Int) = arrayOfNulls(size) + + fun fromMediaStore( + uri: Uri, + title: String, + dateAdded: Long, + duration: Long, + ) = Recording( + uri, + title, + Date(dateAdded * 1000), + duration, + ) + } +} diff --git a/app/src/main/java/org/lineageos/recorder/status/UiStatus.kt b/app/src/main/java/org/lineageos/recorder/models/UiStatus.kt similarity index 81% rename from app/src/main/java/org/lineageos/recorder/status/UiStatus.kt rename to app/src/main/java/org/lineageos/recorder/models/UiStatus.kt index 25c62a07..f4cdb692 100644 --- a/app/src/main/java/org/lineageos/recorder/status/UiStatus.kt +++ b/app/src/main/java/org/lineageos/recorder/models/UiStatus.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -package org.lineageos.recorder.status +package org.lineageos.recorder.models enum class UiStatus { READY, diff --git a/app/src/main/java/org/lineageos/recorder/query/Query.kt b/app/src/main/java/org/lineageos/recorder/query/Query.kt new file mode 100644 index 00000000..79a927d3 --- /dev/null +++ b/app/src/main/java/org/lineageos/recorder/query/Query.kt @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: 2023 The LineageOS Project + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.lineageos.recorder.query + +typealias Column = String + +sealed interface Node { + fun build(): String = when (this) { + is Eq -> "${lhs.build()} = ${rhs.build()}" + is Or -> "(${lhs.build()}) OR (${rhs.build()})" + is And -> "(${lhs.build()}) AND (${rhs.build()})" + is Literal<*> -> "$`val`" + } +} + +private class Eq(val lhs: Node, val rhs: Node) : Node +private class Or(val lhs: Node, val rhs: Node) : Node +private class And(val lhs: Node, val rhs: Node) : Node +private class Literal(val `val`: T) : Node + +class Query(val root: Node) { + fun build() = root.build() + + companion object { + const val ARG = "?" + } +} + +infix fun Query.or(other: Query) = Query(Or(this.root, other.root)) +infix fun Query.and(other: Query) = Query(And(this.root, other.root)) +infix fun Query.eq(other: Query) = Query(Eq(this.root, other.root)) +infix fun Column.eq(other: T) = Query(Literal(this)) eq Query(Literal(other)) + +fun Iterable.join( + func: Query.(other: Query) -> Query, +) = reduceOrNull(func) diff --git a/app/src/main/java/org/lineageos/recorder/repository/RecordingsRepository.kt b/app/src/main/java/org/lineageos/recorder/repository/RecordingsRepository.kt new file mode 100644 index 00000000..d29c230d --- /dev/null +++ b/app/src/main/java/org/lineageos/recorder/repository/RecordingsRepository.kt @@ -0,0 +1,92 @@ +/* + * SPDX-FileCopyrightText: 2024 The LineageOS Project + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.lineageos.recorder.repository + +import android.content.ContentValues +import android.content.Context +import android.os.Build +import android.provider.MediaStore +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.lineageos.recorder.flow.RecordingsFlow +import java.io.FileOutputStream +import java.io.IOException +import java.nio.file.Files +import java.nio.file.Path + +object RecordingsRepository { + private val LOG_TAG = this::class.simpleName!! + + private const val ARTIST = "Recorder" + + private const val ALBUM = "Sound records" + + private const val PATH = "Recordings/${ALBUM}" + private const val PATH_LEGACY = "Music/${ALBUM}" + + fun recordings(context: Context) = RecordingsFlow(context).flowData() + + suspend fun addRecordingToContentProvider( + context: Context, path: Path, mimeType: String + ) = withContext(Dispatchers.IO) { + val contentResolver = context.contentResolver + + val uri = contentResolver.insert( + MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), + buildCv(path, mimeType) + ) ?: run { + Log.e(LOG_TAG, "Failed to insert ${path.toAbsolutePath()}") + + return@withContext null + } + + return@withContext try { + contentResolver.openFileDescriptor( + uri, "w", null + )?.use { pfd -> + FileOutputStream(pfd.fileDescriptor).use { oStream -> + Files.copy(path, oStream) + } + val values = ContentValues().apply { + put(MediaStore.MediaColumns.IS_PENDING, 0) + } + contentResolver.update(uri, values, null, null) + try { + Files.delete(path) + } catch (e: IOException) { + Log.w(LOG_TAG, "Failed to delete tmp file") + } + + uri.toString() + } + } catch (e: IOException) { + Log.e(LOG_TAG, "Failed to write into MediaStore", e) + + null + } + } + + private fun buildCv(path: Path, mimeType: String) = ContentValues().apply { + val name = path.fileName.toString() + + put(MediaStore.Audio.Media.DISPLAY_NAME, name) + put(MediaStore.Audio.Media.TITLE, name) + put(MediaStore.Audio.Media.MIME_TYPE, mimeType) + put(MediaStore.Audio.Media.ARTIST, ARTIST) + put(MediaStore.Audio.Media.ALBUM, ALBUM) + put(MediaStore.Audio.Media.DATE_ADDED, System.currentTimeMillis() / 1000L) + put( + MediaStore.Audio.Media.RELATIVE_PATH, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PATH + } else { + PATH_LEGACY + } + ) + put(MediaStore.Audio.Media.IS_PENDING, 1) + } +} diff --git a/app/src/main/java/org/lineageos/recorder/service/SoundRecorderService.kt b/app/src/main/java/org/lineageos/recorder/service/SoundRecorderService.kt index b725a0c6..d586f3c5 100644 --- a/app/src/main/java/org/lineageos/recorder/service/SoundRecorderService.kt +++ b/app/src/main/java/org/lineageos/recorder/service/SoundRecorderService.kt @@ -10,7 +10,6 @@ import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent -import android.app.Service import android.content.BroadcastReceiver import android.content.Context import android.content.Intent @@ -31,12 +30,14 @@ import android.util.Log import androidx.annotation.GuardedBy import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat +import androidx.lifecycle.LifecycleService +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch import org.lineageos.recorder.ListActivity import org.lineageos.recorder.R import org.lineageos.recorder.RecorderActivity -import org.lineageos.recorder.status.UiStatus -import org.lineageos.recorder.task.AddRecordingToContentProviderTask -import org.lineageos.recorder.task.TaskExecutor +import org.lineageos.recorder.models.UiStatus +import org.lineageos.recorder.repository.RecordingsRepository import org.lineageos.recorder.utils.PreferencesManager import org.lineageos.recorder.utils.RecordIntentHelper import java.io.IOException @@ -46,7 +47,7 @@ import java.nio.file.Path import java.util.Timer import java.util.TimerTask -class SoundRecorderService : Service() { +class SoundRecorderService : LifecycleService() { // System services private val notificationManager by lazy { getSystemService(NotificationManager::class.java) @@ -56,8 +57,6 @@ class SoundRecorderService : Service() { PreferencesManager(this) } - private val taskExecutor = TaskExecutor() - private val lock = Any() @GuardedBy("lock") @@ -95,7 +94,11 @@ class SoundRecorderService : Service() { } } - override fun onBind(intent: Intent): IBinder = messenger.binder + override fun onBind(intent: Intent): IBinder { + super.onBind(intent) + + return messenger.binder + } override fun onCreate() { super.onCreate() @@ -119,27 +122,31 @@ class SoundRecorderService : Service() { unregisterClients() - taskExecutor.terminate(null) - super.onDestroy() } - override fun onStartCommand(intent: Intent, flags: Int, startId: Int) = when (intent.action) { - ACTION_START -> intent.getStringExtra(EXTRA_FILE_NAME)?.let { - if (startRecording(it)) { - START_STICKY - } else { - START_NOT_STICKY - } - } ?: START_NOT_STICKY + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + super.onStartCommand(intent, flags, startId) - ACTION_STOP -> if (stopRecording()) START_STICKY else START_NOT_STICKY + return intent?.let { + when (it.action) { + ACTION_START -> it.getStringExtra(EXTRA_FILE_NAME)?.let { fileName -> + if (startRecording(fileName)) { + START_STICKY + } else { + START_NOT_STICKY + } + } ?: START_NOT_STICKY + + ACTION_STOP -> if (stopRecording()) START_STICKY else START_NOT_STICKY - ACTION_PAUSE -> if (pauseRecording()) START_STICKY else START_NOT_STICKY + ACTION_PAUSE -> if (pauseRecording()) START_STICKY else START_NOT_STICKY - ACTION_RESUME -> if (resumeRecording()) START_STICKY else START_NOT_STICKY + ACTION_RESUME -> if (resumeRecording()) START_STICKY else START_NOT_STICKY - else -> START_NOT_STICKY + else -> START_NOT_STICKY + } + } ?: START_NOT_STICKY } private fun startRecording(fileName: String): Boolean { @@ -196,13 +203,17 @@ class SoundRecorderService : Service() { val success = recorder.stopRecording() return recordPath?.takeIf { success }?.let { - taskExecutor.runTask( - AddRecordingToContentProviderTask( - contentResolver, + lifecycleScope.launch { + RecordingsRepository.addRecordingToContentProvider( + this@SoundRecorderService, it, recorder.mimeType - ), { uri: String? -> onRecordCompleted(uri) } - ) { Log.e(TAG, "Failed to save recording") } + )?.also { uri -> + onRecordCompleted(uri) + } ?: run { + Log.e(TAG, "Failed to save recording") + } + } true } ?: run { diff --git a/app/src/main/java/org/lineageos/recorder/task/AddRecordingToContentProviderTask.kt b/app/src/main/java/org/lineageos/recorder/task/AddRecordingToContentProviderTask.kt deleted file mode 100644 index cdae0e5d..00000000 --- a/app/src/main/java/org/lineageos/recorder/task/AddRecordingToContentProviderTask.kt +++ /dev/null @@ -1,88 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021-2024 The LineageOS Project - * SPDX-License-Identifier: Apache-2.0 - */ - -package org.lineageos.recorder.task - -import android.content.ContentResolver -import android.content.ContentValues -import android.os.Build -import android.provider.MediaStore -import android.util.Log -import java.io.FileOutputStream -import java.io.IOException -import java.nio.file.Files -import java.nio.file.Path -import java.util.concurrent.Callable - -class AddRecordingToContentProviderTask( - private val contentResolver: ContentResolver, - private val path: Path, - private val mimeType: String -) : Callable { - override fun call(): String? { - val uri = contentResolver.insert( - MediaStore.Audio.Media.getContentUri( - MediaStore.VOLUME_EXTERNAL_PRIMARY - ), buildCv(path) - ) ?: run { - Log.e(TAG, "Failed to insert " + path.toAbsolutePath().toString()) - return null - } - - return try { - contentResolver.openFileDescriptor(uri, "w", null)?.use { pfd -> - FileOutputStream(pfd.fileDescriptor).use { oStream -> - Files.copy( - path, oStream - ) - } - val values = ContentValues() - values.put(MediaStore.MediaColumns.IS_PENDING, 0) - contentResolver.update(uri, values, null, null) - try { - Files.delete(path) - } catch (e: IOException) { - Log.w(TAG, "Failed to delete tmp file") - } - return uri.toString() - } - } catch (e: IOException) { - Log.e(TAG, "Failed to write into MediaStore", e) - return null - } - } - - private fun buildCv(path: Path): ContentValues { - val name = path.fileName.toString() - val values = ContentValues() - values.put(MediaStore.Audio.Media.DISPLAY_NAME, name) - values.put(MediaStore.Audio.Media.TITLE, name) - values.put(MediaStore.Audio.Media.MIME_TYPE, mimeType) - values.put(MediaStore.Audio.Media.ARTIST, ARTIST) - values.put(MediaStore.Audio.Media.ALBUM, ALBUM) - values.put(MediaStore.Audio.Media.DATE_ADDED, System.currentTimeMillis() / 1000L) - values.put( - MediaStore.Audio.Media.RELATIVE_PATH, - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PATH - } else { - PATH_LEGACY - } - ) - values.put(MediaStore.Audio.Media.IS_PENDING, 1) - return values - } - - companion object { - private const val TAG = "AddRecordingToContentProviderTask" - - private const val ARTIST = "Recorder" - - private const val ALBUM = "Sound records" - - private const val PATH = "Recordings/$ALBUM" - private const val PATH_LEGACY = "Music/$ALBUM" - } -} diff --git a/app/src/main/java/org/lineageos/recorder/task/DeleteAllRecordingsTask.kt b/app/src/main/java/org/lineageos/recorder/task/DeleteAllRecordingsTask.kt deleted file mode 100644 index 6e9a5c13..00000000 --- a/app/src/main/java/org/lineageos/recorder/task/DeleteAllRecordingsTask.kt +++ /dev/null @@ -1,20 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021-2024 The LineageOS Project - * SPDX-License-Identifier: Apache-2.0 - */ - -package org.lineageos.recorder.task - -import android.content.ContentResolver -import android.net.Uri - -class DeleteAllRecordingsTask( - private val contentResolver: ContentResolver, - private val uris: List, -) : Runnable { - override fun run() { - uris.forEach { - contentResolver.delete(it, null, null) - } - } -} diff --git a/app/src/main/java/org/lineageos/recorder/task/DeleteRecordingTask.kt b/app/src/main/java/org/lineageos/recorder/task/DeleteRecordingTask.kt deleted file mode 100644 index 8c9b2d50..00000000 --- a/app/src/main/java/org/lineageos/recorder/task/DeleteRecordingTask.kt +++ /dev/null @@ -1,27 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021-2024 The LineageOS Project - * SPDX-License-Identifier: Apache-2.0 - */ - -package org.lineageos.recorder.task - -import android.content.ContentResolver -import android.net.Uri -import android.util.Log - -class DeleteRecordingTask( - private val contentResolver: ContentResolver, - private val uri: Uri, -) : Runnable { - override fun run() { - try { - contentResolver.delete(uri, null, null) - } catch (e: SecurityException) { - Log.e(TAG, "Failed to delete recording", e) - } - } - - companion object { - private const val TAG = "DeleteRecordingTask" - } -} diff --git a/app/src/main/java/org/lineageos/recorder/task/GetRecordingsTask.kt b/app/src/main/java/org/lineageos/recorder/task/GetRecordingsTask.kt deleted file mode 100644 index 97348d33..00000000 --- a/app/src/main/java/org/lineageos/recorder/task/GetRecordingsTask.kt +++ /dev/null @@ -1,64 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021-2024 The LineageOS Project - * SPDX-License-Identifier: Apache-2.0 - */ - -package org.lineageos.recorder.task - -import android.content.ContentResolver -import android.content.ContentUris -import android.provider.MediaStore -import org.lineageos.recorder.list.RecordingData -import java.time.Instant -import java.time.LocalDateTime -import java.time.ZoneId -import java.util.concurrent.Callable - -class GetRecordingsTask( - private val packageName: String, - private val contentResolver: ContentResolver, -) : Callable> { - override fun call(): List { - val list = mutableListOf() - - contentResolver.query( - MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), - PROJECTION, - "${MediaStore.Audio.Media.OWNER_PACKAGE_NAME}=?", - arrayOf( - packageName, - ), - MY_RECORDS_SORT - )?.use { cursor -> - if (cursor.moveToFirst()) { - do { - val id = cursor.getLong(0) - val uri = ContentUris.withAppendedId( - MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, id - ) - val name = cursor.getString(1) - val timeStamp = cursor.getLong(2) - val dateTime = LocalDateTime.ofInstant( - Instant.ofEpochSecond(timeStamp), ZoneId.systemDefault() - ) - val duration = cursor.getLong(3) - - list.add(RecordingData(uri, name, dateTime, duration)) - } while (cursor.moveToNext()) - } - } - - return list - } - - companion object { - private val PROJECTION = arrayOf( - MediaStore.Audio.Media._ID, - MediaStore.Audio.Media.DISPLAY_NAME, - MediaStore.Audio.Media.DATE_ADDED, - MediaStore.Audio.Media.DURATION - ) - - private const val MY_RECORDS_SORT = "${MediaStore.Audio.Media.DATE_ADDED} DESC" - } -} diff --git a/app/src/main/java/org/lineageos/recorder/task/RenameRecordingTask.kt b/app/src/main/java/org/lineageos/recorder/task/RenameRecordingTask.kt deleted file mode 100644 index ef32d2c4..00000000 --- a/app/src/main/java/org/lineageos/recorder/task/RenameRecordingTask.kt +++ /dev/null @@ -1,28 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021-2024 The LineageOS Project - * SPDX-License-Identifier: Apache-2.0 - */ - -package org.lineageos.recorder.task - -import android.content.ContentResolver -import android.content.ContentValues -import android.net.Uri -import android.provider.MediaStore -import java.util.concurrent.Callable - -class RenameRecordingTask( - private val contentResolver: ContentResolver, - private val uri: Uri, - private val newName: String -) : Callable { - override fun call(): Boolean { - val contentValues = ContentValues().apply { - put(MediaStore.Audio.Media.DISPLAY_NAME, newName) - put(MediaStore.Audio.Media.TITLE, newName) - } - - val updated = contentResolver.update(uri, contentValues, null, null) - return updated == 1 - } -} diff --git a/app/src/main/java/org/lineageos/recorder/task/TaskExecutor.kt b/app/src/main/java/org/lineageos/recorder/task/TaskExecutor.kt deleted file mode 100644 index de291807..00000000 --- a/app/src/main/java/org/lineageos/recorder/task/TaskExecutor.kt +++ /dev/null @@ -1,138 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021-2024 The LineageOS Project - * SPDX-License-Identifier: Apache-2.0 - */ - -package org.lineageos.recorder.task - -import android.os.Handler -import android.os.Looper -import android.util.Log -import androidx.annotation.MainThread -import androidx.annotation.WorkerThread -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.LifecycleOwner -import java.util.concurrent.Callable -import java.util.concurrent.ExecutionException -import java.util.concurrent.Executors -import java.util.concurrent.Future -import java.util.concurrent.TimeUnit -import java.util.concurrent.TimeoutException -import java.util.function.Consumer - -class TaskExecutor : LifecycleEventObserver { - private val executor = Executors.newFixedThreadPool(2) - private val handler = Handler(Looper.getMainLooper()) - private val execFutures = mutableListOf>() - - override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { - if (event == Lifecycle.Event.ON_DESTROY) { - terminate(source) - } - } - - @Synchronized - fun runTask( - @WorkerThread callable: Callable, - @MainThread consumer: Consumer - ) { - val future = executor.submit(callable) - execFutures.add(future) - try { - val result = future[1, TimeUnit.MINUTES] - // It's completed, remove to free memory - execFutures.remove(future) - // Post result - handler.post { consumer.accept(result) } - } catch (e: InterruptedException) { - Log.w(TAG, e) - } catch (e: ExecutionException) { - throw RuntimeException( - "An error occurred while executing task", - e.cause - ) - } catch (e: TimeoutException) { - throw RuntimeException( - "An error occurred while executing task", - e.cause - ) - } - } - - @Synchronized - fun runTask( - @WorkerThread callable: Callable, - @MainThread ifPresent: Consumer, - @MainThread ifNotPresent: Runnable - ) { - runTask(callable) { opt: T? -> - opt?.also { - ifPresent.accept(it) - } ?: ifNotPresent.run() - } - } - - @Synchronized - fun runTask( - @WorkerThread task: Runnable, - @MainThread callback: Runnable - ) { - val future = executor.submit(task) - execFutures.add(future) - try { - future[1, TimeUnit.MINUTES] - // It's completed, remove to free memory - execFutures.remove(future) - // Post result - handler.post(callback) - } catch (e: InterruptedException) { - Log.w(TAG, e) - } catch (e: ExecutionException) { - throw RuntimeException( - "An error occurred while executing task", - e.cause - ) - } catch (e: TimeoutException) { - throw RuntimeException( - "An error occurred while executing task", - e.cause - ) - } - } - - fun terminate(owner: LifecycleOwner?) { - // Unsubscribe - owner?.lifecycle?.removeObserver(this) - - // Terminate all pending jobs - executor.shutdown() - if (hasUnfinishedTasks()) { - try { - if (!executor.awaitTermination(250, TimeUnit.MILLISECONDS)) { - executor.shutdownNow() - executor.awaitTermination(100, TimeUnit.MILLISECONDS) - } - } catch (e: InterruptedException) { - Log.e(TAG, "Interrupted", e) - // (Re-)Cancel if current thread also interrupted - executor.shutdownNow() - // Preserve interrupt status - Thread.currentThread().interrupt() - } - } - } - - private fun hasUnfinishedTasks(): Boolean { - for (future in execFutures) { - if (!future.isDone) { - return true - } - } - return false - } - - companion object { - private const val TAG = "TaskExecutor" - } -} diff --git a/app/src/main/java/org/lineageos/recorder/utils/RecordIntentHelper.kt b/app/src/main/java/org/lineageos/recorder/utils/RecordIntentHelper.kt index 65b16171..7a47d837 100644 --- a/app/src/main/java/org/lineageos/recorder/utils/RecordIntentHelper.kt +++ b/app/src/main/java/org/lineageos/recorder/utils/RecordIntentHelper.kt @@ -21,7 +21,7 @@ object RecordIntentHelper { return chooserIntent } - fun getShareIntents(uris: MutableList, mimeType: String?): Intent { + fun getShareIntents(uris: List, mimeType: String?): Intent { val intent = Intent(Intent.ACTION_SEND_MULTIPLE) intent.setType(mimeType) intent.putExtras( diff --git a/app/src/main/java/org/lineageos/recorder/viewmodels/RecordingsViewModel.kt b/app/src/main/java/org/lineageos/recorder/viewmodels/RecordingsViewModel.kt new file mode 100644 index 00000000..128521ae --- /dev/null +++ b/app/src/main/java/org/lineageos/recorder/viewmodels/RecordingsViewModel.kt @@ -0,0 +1,80 @@ +/* + * SPDX-FileCopyrightText: 2024 The LineageOS Project + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.lineageos.recorder.viewmodels + +import android.app.Application +import android.content.ContentValues +import android.net.Uri +import android.provider.MediaStore +import android.util.Log +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.withContext +import org.lineageos.recorder.ext.applicationContext +import org.lineageos.recorder.models.Recording +import org.lineageos.recorder.repository.RecordingsRepository +import org.lineageos.recorder.utils.Utils + +class RecordingsViewModel(application: Application) : AndroidViewModel(application) { + private val contentResolver = applicationContext.contentResolver + + val recordings = RecordingsRepository.recordings(applicationContext) + .flowOn(Dispatchers.IO) + .stateIn( + viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = listOf(), + ) + + val inSelectionMode = MutableLiveData(false) + + suspend fun deleteRecordings(vararg uris: Uri) { + withContext(Dispatchers.IO) { + for (uri in uris) { + try { + contentResolver.delete( + uri, + null, + null, + ) + } catch (e: SecurityException) { + Log.e(LOG_TAG, "Failed to delete recording", e) + } + } + + Utils.cancelShareNotification(applicationContext) + } + } + + suspend fun deleteRecordings( + vararg recordings: Recording + ) = deleteRecordings(*recordings.map { it.uri }.toTypedArray()) + + suspend fun renameRecording(recording: Recording, newName: String) { + withContext(Dispatchers.IO) { + val contentValues = ContentValues().apply { + put(MediaStore.Audio.Media.DISPLAY_NAME, newName) + put(MediaStore.Audio.Media.TITLE, newName) + } + + contentResolver.update( + recording.uri, + contentValues, + null, + null + ) + } + } + + companion object { + private val LOG_TAG = RecordingsViewModel::class.simpleName!! + } +} diff --git a/app/src/main/res/menu/menu_list_action_mode.xml b/app/src/main/res/menu/menu_list_action_mode.xml index f933b26d..06d12ebe 100644 --- a/app/src/main/res/menu/menu_list_action_mode.xml +++ b/app/src/main/res/menu/menu_list_action_mode.xml @@ -7,13 +7,13 @@ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9008a2a2..5a20d4f6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -118,4 +118,10 @@ Delete selected Are you sure you want to delete the selected recordings? This cannot be undone + + + + %d selected + %d selected +