Merge pull request #205 from esensar/feature/recycle-bin

Add support for recycle bin
This commit is contained in:
Tibor Kaputa 2023-07-27 16:03:58 +02:00 committed by GitHub
commit 272f5a8b21
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 1079 additions and 220 deletions

View File

@ -63,7 +63,7 @@ android {
} }
dependencies { dependencies {
implementation 'com.github.SimpleMobileTools:Simple-Commons:42733f39a4' implementation 'com.github.SimpleMobileTools:Simple-Commons:fa61be64d8'
implementation 'org.greenrobot:eventbus:3.3.1' implementation 'org.greenrobot:eventbus:3.3.1'
implementation 'com.github.Armen101:AudioRecordView:1.0.4' implementation 'com.github.Armen101:AudioRecordView:1.0.4'
implementation 'androidx.documentfile:documentfile:1.0.1' implementation 'androidx.documentfile:documentfile:1.0.1'

View File

@ -12,6 +12,7 @@ import com.simplemobiletools.commons.models.FAQItem
import com.simplemobiletools.voicerecorder.BuildConfig import com.simplemobiletools.voicerecorder.BuildConfig
import com.simplemobiletools.voicerecorder.R import com.simplemobiletools.voicerecorder.R
import com.simplemobiletools.voicerecorder.adapters.ViewPagerAdapter import com.simplemobiletools.voicerecorder.adapters.ViewPagerAdapter
import com.simplemobiletools.voicerecorder.extensions.checkRecycleBinItems
import com.simplemobiletools.voicerecorder.extensions.config import com.simplemobiletools.voicerecorder.extensions.config
import com.simplemobiletools.voicerecorder.helpers.STOP_AMPLITUDE_UPDATE import com.simplemobiletools.voicerecorder.helpers.STOP_AMPLITUDE_UPDATE
import com.simplemobiletools.voicerecorder.models.Events import com.simplemobiletools.voicerecorder.models.Events
@ -40,6 +41,10 @@ class MainActivity : SimpleActivity() {
return return
} }
if (savedInstanceState == null) {
checkRecycleBinItems()
}
handlePermission(PERMISSION_RECORD_AUDIO) { handlePermission(PERMISSION_RECORD_AUDIO) {
if (it) { if (it) {
tryInitVoiceRecorder() tryInitVoiceRecorder()
@ -65,6 +70,9 @@ class MainActivity : SimpleActivity() {
super.onResume() super.onResume()
setupTabColors() setupTabColors()
updateMenuColors() updateMenuColors()
if (getPagerAdapter()?.showRecycleBin != config.useRecycleBin) {
setupViewPager()
}
getPagerAdapter()?.onResume() getPagerAdapter()?.onResume()
} }
@ -110,7 +118,9 @@ class MainActivity : SimpleActivity() {
main_menu.setupMenu() main_menu.setupMenu()
main_menu.onSearchOpenListener = { main_menu.onSearchOpenListener = {
view_pager.currentItem = 1 if (view_pager.currentItem == 0) {
view_pager.currentItem = 1
}
} }
main_menu.onSearchTextChangedListener = { text -> main_menu.onSearchTextChangedListener = { text ->
@ -149,8 +159,12 @@ class MainActivity : SimpleActivity() {
private fun setupViewPager() { private fun setupViewPager() {
main_tabs_holder.removeAllTabs() main_tabs_holder.removeAllTabs()
val tabDrawables = arrayOf(R.drawable.ic_microphone_vector, R.drawable.ic_headset_vector) var tabDrawables = arrayOf(R.drawable.ic_microphone_vector, R.drawable.ic_headset_vector)
val tabLabels = arrayOf(R.string.recorder, R.string.player) var tabLabels = arrayOf(R.string.recorder, R.string.player)
if (config.useRecycleBin) {
tabDrawables += R.drawable.ic_delete_vector
tabLabels += R.string.recycle_bin
}
tabDrawables.forEachIndexed { i, drawableId -> tabDrawables.forEachIndexed { i, drawableId ->
main_tabs_holder.newTab().setCustomView(R.layout.bottom_tablayout_item).apply { main_tabs_holder.newTab().setCustomView(R.layout.bottom_tablayout_item).apply {
@ -164,7 +178,7 @@ class MainActivity : SimpleActivity() {
main_tabs_holder.onTabSelectionChanged( main_tabs_holder.onTabSelectionChanged(
tabUnselectedAction = { tabUnselectedAction = {
updateBottomTabItemColors(it.customView, false) updateBottomTabItemColors(it.customView, false)
if (it.position == 1) { if (it.position == 1 || it.position == 2) {
main_menu.closeSearch() main_menu.closeSearch()
} }
}, },
@ -174,7 +188,8 @@ class MainActivity : SimpleActivity() {
} }
) )
view_pager.adapter = ViewPagerAdapter(this) view_pager.adapter = ViewPagerAdapter(this, config.useRecycleBin)
view_pager.offscreenPageLimit = 2
view_pager.onPageChangeListener { view_pager.onPageChangeListener {
main_tabs_holder.getTabAt(it)?.select() main_tabs_holder.getTabAt(it)?.select()
(view_pager.adapter as ViewPagerAdapter).finishActMode() (view_pager.adapter as ViewPagerAdapter).finishActMode()

View File

@ -3,24 +3,26 @@ package com.simplemobiletools.voicerecorder.activities
import android.content.Intent import android.content.Intent
import android.media.MediaRecorder import android.media.MediaRecorder
import android.os.Bundle import android.os.Bundle
import com.simplemobiletools.commons.dialogs.ChangeDateTimeFormatDialog import com.simplemobiletools.commons.dialogs.*
import com.simplemobiletools.commons.dialogs.FeatureLockedDialog
import com.simplemobiletools.commons.dialogs.FilePickerDialog
import com.simplemobiletools.commons.dialogs.RadioGroupDialog
import com.simplemobiletools.commons.extensions.* import com.simplemobiletools.commons.extensions.*
import com.simplemobiletools.commons.helpers.* import com.simplemobiletools.commons.helpers.*
import com.simplemobiletools.commons.models.RadioItem import com.simplemobiletools.commons.models.RadioItem
import com.simplemobiletools.voicerecorder.R import com.simplemobiletools.voicerecorder.R
import com.simplemobiletools.voicerecorder.extensions.config import com.simplemobiletools.voicerecorder.extensions.config
import com.simplemobiletools.voicerecorder.extensions.emptyTheRecycleBin
import com.simplemobiletools.voicerecorder.extensions.getAllRecordings
import com.simplemobiletools.voicerecorder.helpers.BITRATES import com.simplemobiletools.voicerecorder.helpers.BITRATES
import com.simplemobiletools.voicerecorder.helpers.EXTENSION_M4A import com.simplemobiletools.voicerecorder.helpers.EXTENSION_M4A
import com.simplemobiletools.voicerecorder.helpers.EXTENSION_MP3 import com.simplemobiletools.voicerecorder.helpers.EXTENSION_MP3
import com.simplemobiletools.voicerecorder.helpers.EXTENSION_OGG import com.simplemobiletools.voicerecorder.helpers.EXTENSION_OGG
import com.simplemobiletools.voicerecorder.models.Events
import kotlinx.android.synthetic.main.activity_settings.* import kotlinx.android.synthetic.main.activity_settings.*
import java.util.* import org.greenrobot.eventbus.EventBus
import java.util.Locale
import kotlin.system.exitProcess import kotlin.system.exitProcess
class SettingsActivity : SimpleActivity() { class SettingsActivity : SimpleActivity() {
private var recycleBinContentSize = 0
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
isMaterialActivity = true isMaterialActivity = true
@ -47,9 +49,11 @@ class SettingsActivity : SimpleActivity() {
setupBitrate() setupBitrate()
setupAudioSource() setupAudioSource()
setupRecordAfterLaunch() setupRecordAfterLaunch()
setupUseRecycleBin()
setupEmptyRecycleBin()
updateTextColors(settings_nested_scrollview) updateTextColors(settings_nested_scrollview)
arrayOf(settings_color_customization_section_label, settings_general_settings_label).forEach { arrayOf(settings_color_customization_section_label, settings_general_settings_label, settings_recycle_bin_label).forEach {
it.setTextColor(getProperPrimaryColor()) it.setTextColor(getProperPrimaryColor())
} }
} }
@ -178,6 +182,48 @@ class SettingsActivity : SimpleActivity() {
} }
} }
private fun setupUseRecycleBin() {
updateRecycleBinButtons()
settings_use_recycle_bin.isChecked = config.useRecycleBin
settings_use_recycle_bin_holder.setOnClickListener {
settings_use_recycle_bin.toggle()
config.useRecycleBin = settings_use_recycle_bin.isChecked
updateRecycleBinButtons()
}
}
private fun updateRecycleBinButtons() {
settings_empty_recycle_bin_holder.beVisibleIf(config.useRecycleBin)
}
private fun setupEmptyRecycleBin() {
ensureBackgroundThread {
try {
recycleBinContentSize = getAllRecordings(trashed = true).sumByInt {
it.size
}
} catch (ignored: Exception) {
}
runOnUiThread {
settings_empty_recycle_bin_size.text = recycleBinContentSize.formatSize()
}
}
settings_empty_recycle_bin_holder.setOnClickListener {
if (recycleBinContentSize == 0) {
toast(R.string.recycle_bin_empty)
} else {
ConfirmationDialog(this, "", R.string.empty_recycle_bin_confirmation, R.string.yes, R.string.no) {
emptyTheRecycleBin()
recycleBinContentSize = 0
settings_empty_recycle_bin_size.text = 0.formatSize()
EventBus.getDefault().post(Events.RecordingTrashUpdated())
}
}
}
}
private fun setupAudioSource() { private fun setupAudioSource() {
settings_audio_source.text = config.getAudioSourceText(config.audioSource) settings_audio_source.text = config.getAudioSourceText(config.audioSource)
settings_audio_source_holder.setOnClickListener { settings_audio_source_holder.setOnClickListener {

View File

@ -1,28 +1,28 @@
package com.simplemobiletools.voicerecorder.adapters package com.simplemobiletools.voicerecorder.adapters
import android.provider.MediaStore
import android.provider.MediaStore.Audio.Media
import android.view.* import android.view.*
import android.widget.PopupMenu import android.widget.PopupMenu
import android.widget.TextView import android.widget.TextView
import androidx.core.net.toUri
import com.qtalk.recyclerviewfastscroller.RecyclerViewFastScroller import com.qtalk.recyclerviewfastscroller.RecyclerViewFastScroller
import com.simplemobiletools.commons.adapters.MyRecyclerViewAdapter import com.simplemobiletools.commons.adapters.MyRecyclerViewAdapter
import com.simplemobiletools.commons.dialogs.ConfirmationDialog
import com.simplemobiletools.commons.extensions.* import com.simplemobiletools.commons.extensions.*
import com.simplemobiletools.commons.helpers.ensureBackgroundThread import com.simplemobiletools.commons.helpers.ensureBackgroundThread
import com.simplemobiletools.commons.helpers.isQPlus import com.simplemobiletools.commons.helpers.isQPlus
import com.simplemobiletools.commons.helpers.isRPlus
import com.simplemobiletools.commons.views.MyRecyclerView import com.simplemobiletools.commons.views.MyRecyclerView
import com.simplemobiletools.voicerecorder.BuildConfig import com.simplemobiletools.voicerecorder.BuildConfig
import com.simplemobiletools.voicerecorder.R import com.simplemobiletools.voicerecorder.R
import com.simplemobiletools.voicerecorder.activities.SimpleActivity import com.simplemobiletools.voicerecorder.activities.SimpleActivity
import com.simplemobiletools.voicerecorder.dialogs.DeleteConfirmationDialog
import com.simplemobiletools.voicerecorder.dialogs.RenameRecordingDialog import com.simplemobiletools.voicerecorder.dialogs.RenameRecordingDialog
import com.simplemobiletools.voicerecorder.extensions.config
import com.simplemobiletools.voicerecorder.extensions.deleteRecordings
import com.simplemobiletools.voicerecorder.extensions.moveRecordingsToRecycleBin
import com.simplemobiletools.voicerecorder.helpers.getAudioFileContentUri import com.simplemobiletools.voicerecorder.helpers.getAudioFileContentUri
import com.simplemobiletools.voicerecorder.interfaces.RefreshRecordingsListener import com.simplemobiletools.voicerecorder.interfaces.RefreshRecordingsListener
import com.simplemobiletools.voicerecorder.models.Events
import com.simplemobiletools.voicerecorder.models.Recording import com.simplemobiletools.voicerecorder.models.Recording
import kotlinx.android.synthetic.main.item_recording.view.* import kotlinx.android.synthetic.main.item_recording.view.*
import java.io.File import org.greenrobot.eventbus.EventBus
class RecordingsAdapter( class RecordingsAdapter(
activity: SimpleActivity, activity: SimpleActivity,
@ -135,12 +135,21 @@ class RecordingsAdapter(
resources.getQuantityString(R.plurals.delete_recordings, itemsCnt, itemsCnt) resources.getQuantityString(R.plurals.delete_recordings, itemsCnt, itemsCnt)
} }
val baseString = R.string.delete_recordings_confirmation val baseString = if (activity.config.useRecycleBin) {
R.string.move_to_recycle_bin_confirmation
} else {
R.string.delete_recordings_confirmation
}
val question = String.format(resources.getString(baseString), items) val question = String.format(resources.getString(baseString), items)
ConfirmationDialog(activity, question) { DeleteConfirmationDialog(activity, question, activity.config.useRecycleBin) { skipRecycleBin ->
ensureBackgroundThread { ensureBackgroundThread {
deleteMediaStoreRecordings() val toRecycleBin = !skipRecycleBin && activity.config.useRecycleBin
if (toRecycleBin) {
moveMediaStoreRecordingsToRecycleBin()
} else {
deleteMediaStoreRecordings()
}
} }
} }
} }
@ -154,38 +163,26 @@ class RecordingsAdapter(
val recordingsToRemove = recordings.filter { selectedKeys.contains(it.id) } as ArrayList<Recording> val recordingsToRemove = recordings.filter { selectedKeys.contains(it.id) } as ArrayList<Recording>
val positions = getSelectedItemPositions() val positions = getSelectedItemPositions()
when { activity.deleteRecordings(recordingsToRemove) { success ->
isRPlus() -> { if (success) {
val fileUris = recordingsToRemove.map { recording ->
"${Media.EXTERNAL_CONTENT_URI}/${recording.id.toLong()}".toUri()
}
activity.deleteSDK30Uris(fileUris) { success ->
if (success) {
doDeleteAnimation(oldRecordingIndex, recordingsToRemove, positions)
}
}
}
isQPlus() -> {
recordingsToRemove.forEach {
val uri = Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
val selection = "${Media._ID} = ?"
val selectionArgs = arrayOf(it.id.toString())
val result = activity.contentResolver.delete(uri, selection, selectionArgs)
if (result == 0) {
recordingsToRemove.forEach {
activity.deleteFile(File(it.path).toFileDirItem(activity))
}
}
}
doDeleteAnimation(oldRecordingIndex, recordingsToRemove, positions) doDeleteAnimation(oldRecordingIndex, recordingsToRemove, positions)
} }
else -> { }
recordingsToRemove.forEach { }
activity.deleteFile(File(it.path).toFileDirItem(activity))
} private fun moveMediaStoreRecordingsToRecycleBin() {
if (selectedKeys.isEmpty()) {
return
}
val oldRecordingIndex = recordings.indexOfFirst { it.id == currRecordingId }
val recordingsToRemove = recordings.filter { selectedKeys.contains(it.id) } as ArrayList<Recording>
val positions = getSelectedItemPositions()
activity.moveRecordingsToRecycleBin(recordingsToRemove) { success ->
if (success) {
doDeleteAnimation(oldRecordingIndex, recordingsToRemove, positions) doDeleteAnimation(oldRecordingIndex, recordingsToRemove, positions)
EventBus.getDefault().post(Events.RecordingTrashUpdated())
} }
} }
} }
@ -269,16 +266,19 @@ class RecordingsAdapter(
renameRecording() renameRecording()
} }
} }
R.id.cab_share -> { R.id.cab_share -> {
executeItemMenuOperation(recordingId) { executeItemMenuOperation(recordingId) {
shareRecordings() shareRecordings()
} }
} }
R.id.cab_open_with -> { R.id.cab_open_with -> {
executeItemMenuOperation(recordingId) { executeItemMenuOperation(recordingId) {
openRecordingWith() openRecordingWith()
} }
} }
R.id.cab_delete -> { R.id.cab_delete -> {
executeItemMenuOperation(recordingId, removeAfterCallback = false) { executeItemMenuOperation(recordingId, removeAfterCallback = false) {
askConfirmDelete() askConfirmDelete()

View File

@ -0,0 +1,219 @@
package com.simplemobiletools.voicerecorder.adapters
import android.view.*
import android.widget.PopupMenu
import android.widget.TextView
import com.qtalk.recyclerviewfastscroller.RecyclerViewFastScroller
import com.simplemobiletools.commons.adapters.MyRecyclerViewAdapter
import com.simplemobiletools.commons.dialogs.ConfirmationDialog
import com.simplemobiletools.commons.extensions.*
import com.simplemobiletools.commons.helpers.ensureBackgroundThread
import com.simplemobiletools.commons.views.MyRecyclerView
import com.simplemobiletools.voicerecorder.R
import com.simplemobiletools.voicerecorder.activities.SimpleActivity
import com.simplemobiletools.voicerecorder.extensions.deleteRecordings
import com.simplemobiletools.voicerecorder.extensions.restoreRecordings
import com.simplemobiletools.voicerecorder.interfaces.RefreshRecordingsListener
import com.simplemobiletools.voicerecorder.models.Events
import com.simplemobiletools.voicerecorder.models.Recording
import kotlinx.android.synthetic.main.item_recording.view.*
import org.greenrobot.eventbus.EventBus
class TrashAdapter(
activity: SimpleActivity,
var recordings: ArrayList<Recording>,
val refreshListener: RefreshRecordingsListener,
recyclerView: MyRecyclerView
) :
MyRecyclerViewAdapter(activity, recyclerView, {}), RecyclerViewFastScroller.OnPopupTextUpdate {
init {
setupDragListener(true)
}
override fun getActionMenuId() = R.menu.cab_trash
override fun prepareActionMode(menu: Menu) {}
override fun actionItemPressed(id: Int) {
if (selectedKeys.isEmpty()) {
return
}
when (id) {
R.id.cab_restore -> restoreRecordings()
R.id.cab_delete -> askConfirmDelete()
R.id.cab_select_all -> selectAll()
}
}
override fun getSelectableItemCount() = recordings.size
override fun getIsItemSelectable(position: Int) = true
override fun getItemSelectionKey(position: Int) = recordings.getOrNull(position)?.id
override fun getItemKeyPosition(key: Int) = recordings.indexOfFirst { it.id == key }
override fun onActionModeCreated() {}
override fun onActionModeDestroyed() {}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = createViewHolder(R.layout.item_recording, parent)
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val recording = recordings[position]
holder.bindView(recording, true, true) { itemView, layoutPosition ->
setupView(itemView, recording)
}
bindViewHolder(holder)
}
override fun getItemCount() = recordings.size
private fun getItemWithKey(key: Int): Recording? = recordings.firstOrNull { it.id == key }
fun updateItems(newItems: ArrayList<Recording>) {
if (newItems.hashCode() != recordings.hashCode()) {
recordings = newItems
notifyDataSetChanged()
finishActMode()
}
}
private fun restoreRecordings() {
if (selectedKeys.isEmpty()) {
return
}
val recordingsToRestore = recordings.filter { selectedKeys.contains(it.id) } as ArrayList<Recording>
val positions = getSelectedItemPositions()
activity.restoreRecordings(recordingsToRestore) { success ->
if (success) {
doDeleteAnimation(recordingsToRestore, positions)
EventBus.getDefault().post(Events.RecordingTrashUpdated())
}
}
}
private fun askConfirmDelete() {
val itemsCnt = selectedKeys.size
val firstItem = getSelectedItems().firstOrNull() ?: return
val items = if (itemsCnt == 1) {
"\"${firstItem.title}\""
} else {
resources.getQuantityString(R.plurals.delete_recordings, itemsCnt, itemsCnt)
}
val baseString = R.string.delete_recordings_confirmation
val question = String.format(resources.getString(baseString), items)
ConfirmationDialog(activity, question) {
ensureBackgroundThread {
deleteMediaStoreRecordings()
}
}
}
private fun deleteMediaStoreRecordings() {
if (selectedKeys.isEmpty()) {
return
}
val recordingsToRemove = recordings.filter { selectedKeys.contains(it.id) } as ArrayList<Recording>
val positions = getSelectedItemPositions()
activity.deleteRecordings(recordingsToRemove) { success ->
if (success) {
doDeleteAnimation(recordingsToRemove, positions)
}
}
}
private fun doDeleteAnimation(recordingsToRemove: ArrayList<Recording>, positions: ArrayList<Int>) {
recordings.removeAll(recordingsToRemove.toSet())
activity.runOnUiThread {
if (recordings.isEmpty()) {
refreshListener.refreshRecordings()
finishActMode()
} else {
positions.sortDescending()
removeSelectedItems(positions)
}
}
}
private fun getSelectedItems() = recordings.filter { selectedKeys.contains(it.id) } as ArrayList<Recording>
private fun setupView(view: View, recording: Recording) {
view.apply {
setupViewBackground(activity)
recording_frame?.isSelected = selectedKeys.contains(recording.id)
arrayListOf<TextView>(recording_title, recording_date, recording_duration, recording_size).forEach {
it.setTextColor(textColor)
}
recording_title.text = recording.title
recording_date.text = recording.timestamp.formatDate(context)
recording_duration.text = recording.duration.getFormattedDuration()
recording_size.text = recording.size.formatSize()
overflow_menu_icon.drawable.apply {
mutate()
setTint(activity.getProperTextColor())
}
overflow_menu_icon.setOnClickListener {
showPopupMenu(overflow_menu_anchor, recording)
}
}
}
override fun onChange(position: Int) = recordings.getOrNull(position)?.title ?: ""
private fun showPopupMenu(view: View, recording: Recording) {
if (selectedKeys.isNotEmpty()) {
selectedKeys.clear()
notifyDataSetChanged()
}
finishActMode()
val theme = activity.getPopupMenuTheme()
val contextTheme = ContextThemeWrapper(activity, theme)
PopupMenu(contextTheme, view, Gravity.END).apply {
inflate(getActionMenuId())
menu.findItem(R.id.cab_select_all).isVisible = false
menu.findItem(R.id.cab_restore).title = resources.getString(R.string.restore_this_file)
setOnMenuItemClickListener { item ->
val recordingId = recording.id
when (item.itemId) {
R.id.cab_restore -> {
executeItemMenuOperation(recordingId, removeAfterCallback = false) {
restoreRecordings()
}
}
R.id.cab_delete -> {
executeItemMenuOperation(recordingId, removeAfterCallback = false) {
askConfirmDelete()
}
}
}
true
}
show()
}
}
private fun executeItemMenuOperation(callId: Int, removeAfterCallback: Boolean = true, callback: () -> Unit) {
selectedKeys.add(callId)
callback()
if (removeAfterCallback) {
selectedKeys.remove(callId)
}
}
}

View File

@ -8,12 +8,18 @@ import com.simplemobiletools.voicerecorder.R
import com.simplemobiletools.voicerecorder.activities.SimpleActivity import com.simplemobiletools.voicerecorder.activities.SimpleActivity
import com.simplemobiletools.voicerecorder.fragments.MyViewPagerFragment import com.simplemobiletools.voicerecorder.fragments.MyViewPagerFragment
import com.simplemobiletools.voicerecorder.fragments.PlayerFragment import com.simplemobiletools.voicerecorder.fragments.PlayerFragment
import com.simplemobiletools.voicerecorder.fragments.TrashFragment
class ViewPagerAdapter(private val activity: SimpleActivity) : PagerAdapter() { class ViewPagerAdapter(private val activity: SimpleActivity, val showRecycleBin: Boolean) : PagerAdapter() {
private val mFragments = SparseArray<MyViewPagerFragment>() private val mFragments = SparseArray<MyViewPagerFragment>()
override fun instantiateItem(container: ViewGroup, position: Int): Any { override fun instantiateItem(container: ViewGroup, position: Int): Any {
val layout = if (position == 0) R.layout.fragment_recorder else R.layout.fragment_player val layout = when (position) {
0 -> R.layout.fragment_recorder
1 -> R.layout.fragment_player
2 -> R.layout.fragment_trash
else -> throw IllegalArgumentException("Invalid position. Count = $count, requested position = $position")
}
val view = activity.layoutInflater.inflate(layout, container, false) val view = activity.layoutInflater.inflate(layout, container, false)
container.addView(view) container.addView(view)
@ -26,7 +32,11 @@ class ViewPagerAdapter(private val activity: SimpleActivity) : PagerAdapter() {
container.removeView(item as View) container.removeView(item as View)
} }
override fun getCount() = 2 override fun getCount() = if (showRecycleBin) {
3
} else {
2
}
override fun isViewFromObject(view: View, item: Any) = view == item override fun isViewFromObject(view: View, item: Any) = view == item
@ -42,9 +52,17 @@ class ViewPagerAdapter(private val activity: SimpleActivity) : PagerAdapter() {
} }
} }
fun finishActMode() = (mFragments[1] as? PlayerFragment)?.finishActMode() fun finishActMode() {
(mFragments[1] as? PlayerFragment)?.finishActMode()
if (showRecycleBin) {
(mFragments[2] as? TrashFragment)?.finishActMode()
}
}
fun searchTextChanged(text: String) { fun searchTextChanged(text: String) {
(mFragments[1] as? PlayerFragment)?.onSearchTextChanged(text) (mFragments[1] as? PlayerFragment)?.onSearchTextChanged(text)
if (showRecycleBin) {
(mFragments[2] as? TrashFragment)?.onSearchTextChanged(text)
}
} }
} }

View File

@ -0,0 +1,39 @@
package com.simplemobiletools.voicerecorder.dialogs
import android.app.Activity
import androidx.appcompat.app.AlertDialog
import com.simplemobiletools.commons.extensions.beGoneIf
import com.simplemobiletools.commons.extensions.getAlertDialogBuilder
import com.simplemobiletools.commons.extensions.setupDialogStuff
import com.simplemobiletools.voicerecorder.R
import kotlinx.android.synthetic.main.dialog_delete_confirmation.view.delete_remember_title
import kotlinx.android.synthetic.main.dialog_delete_confirmation.view.skip_the_recycle_bin_checkbox
class DeleteConfirmationDialog(
private val activity: Activity,
private val message: String,
private val showSkipRecycleBinOption: Boolean,
private val callback: (skipRecycleBin: Boolean) -> Unit
) {
private var dialog: AlertDialog? = null
val view = activity.layoutInflater.inflate(R.layout.dialog_delete_confirmation, null)!!
init {
view.delete_remember_title.text = message
view.skip_the_recycle_bin_checkbox.beGoneIf(!showSkipRecycleBinOption)
activity.getAlertDialogBuilder()
.setPositiveButton(R.string.yes) { _, _ -> dialogConfirmed() }
.setNegativeButton(R.string.no, null)
.apply {
activity.setupDialogStuff(view, this) { alertDialog ->
dialog = alertDialog
}
}
}
private fun dialogConfirmed() {
dialog?.dismiss()
callback(view.skip_the_recycle_bin_checkbox.isChecked)
}
}

View File

@ -0,0 +1,183 @@
package com.simplemobiletools.voicerecorder.extensions
import android.content.ContentValues
import android.provider.MediaStore
import android.provider.MediaStore.Audio.Media
import com.simplemobiletools.commons.activities.BaseSimpleActivity
import com.simplemobiletools.commons.extensions.deleteFile
import com.simplemobiletools.commons.extensions.getParentPath
import com.simplemobiletools.commons.extensions.toFileDirItem
import com.simplemobiletools.commons.helpers.*
import com.simplemobiletools.commons.models.FileDirItem
import com.simplemobiletools.voicerecorder.helpers.getAudioFileContentUri
import com.simplemobiletools.voicerecorder.models.Recording
import java.io.File
fun BaseSimpleActivity.deleteRecordings(recordingsToRemove: Collection<Recording>, callback: (success: Boolean) -> Unit) {
when {
isRPlus() -> {
val fileUris = recordingsToRemove.map { recording ->
getAudioFileContentUri(recording.id.toLong())
}
deleteSDK30Uris(fileUris, callback)
}
isQPlus() -> {
recordingsToRemove.forEach {
val uri = Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
val selection = "${Media._ID} = ?"
val selectionArgs = arrayOf(it.id.toString())
val result = contentResolver.delete(uri, selection, selectionArgs)
if (result == 0) {
val fileDirItem = File(it.path).toFileDirItem(this)
deleteFile(fileDirItem)
}
}
callback(true)
}
else -> {
recordingsToRemove.forEach {
val fileDirItem = File(it.path).toFileDirItem(this)
deleteFile(fileDirItem)
}
callback(true)
}
}
}
fun BaseSimpleActivity.restoreRecordings(recordingsToRestore: Collection<Recording>, callback: (success: Boolean) -> Unit) {
when {
isRPlus() -> {
val fileUris = recordingsToRestore.map { recording ->
getAudioFileContentUri(recording.id.toLong())
}
trashSDK30Uris(fileUris, false, callback)
}
isQPlus() -> {
var wait = false
recordingsToRestore.forEach {
val uri = Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
val selection = "${Media._ID} = ?"
val selectionArgs = arrayOf(it.id.toString())
val values = ContentValues().apply {
put(Media.IS_TRASHED, 0)
}
val result = contentResolver.update(uri, values, selection, selectionArgs)
if (result == 0) {
wait = true
copyMoveFilesTo(
fileDirItems = arrayListOf(File(it.path).toFileDirItem(this)),
source = it.path.getParentPath(),
destination = config.saveRecordingsFolder,
isCopyOperation = false,
copyPhotoVideoOnly = false,
copyHidden = false
) {
callback(true)
}
}
}
if (!wait) {
callback(true)
}
}
else -> {
copyMoveFilesTo(
fileDirItems = recordingsToRestore.map { File(it.path).toFileDirItem(this) }.toMutableList() as ArrayList<FileDirItem>,
source = recordingsToRestore.first().path.getParentPath(),
destination = config.saveRecordingsFolder,
isCopyOperation = false,
copyPhotoVideoOnly = false,
copyHidden = false
) {
callback(true)
}
}
}
}
fun BaseSimpleActivity.moveRecordingsToRecycleBin(recordingsToMove: Collection<Recording>, callback: (success: Boolean) -> Unit) {
when {
isRPlus() -> {
val fileUris = recordingsToMove.map { recording ->
getAudioFileContentUri(recording.id.toLong())
}
trashSDK30Uris(fileUris, true, callback)
}
isQPlus() -> {
var wait = false
recordingsToMove.forEach {
val uri = Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
val selection = "${Media._ID} = ?"
val selectionArgs = arrayOf(it.id.toString())
val values = ContentValues().apply {
put(Media.IS_TRASHED, 1)
}
val result = contentResolver.update(uri, values, selection, selectionArgs)
if (result == 0) {
wait = true
copyMoveFilesTo(
fileDirItems = arrayListOf(File(it.path).toFileDirItem(this)),
source = it.path.getParentPath(),
destination = getOrCreateTrashFolder(),
isCopyOperation = false,
copyPhotoVideoOnly = false,
copyHidden = false
) {
callback(true)
}
}
}
if (!wait) {
callback(true)
}
}
else -> {
copyMoveFilesTo(
fileDirItems = recordingsToMove.map { File(it.path).toFileDirItem(this) }.toMutableList() as ArrayList<FileDirItem>,
source = recordingsToMove.first().path.getParentPath(),
destination = getOrCreateTrashFolder(),
isCopyOperation = false,
copyPhotoVideoOnly = false,
copyHidden = false
) {
callback(true)
}
}
}
}
fun BaseSimpleActivity.checkRecycleBinItems() {
if (isQPlus()) {
// System is handling recycle bin on Q+ devices
return
}
if (config.useRecycleBin && config.lastRecycleBinCheck < System.currentTimeMillis() - DAY_SECONDS * 1000) {
config.lastRecycleBinCheck = System.currentTimeMillis()
ensureBackgroundThread {
try {
val recordingsToRemove = getLegacyRecordings(trashed = true).filter { it.timestamp < System.currentTimeMillis() - MONTH_SECONDS * 1000L }
if (recordingsToRemove.isNotEmpty()) {
deleteRecordings(recordingsToRemove) {}
}
} catch (e: Exception) {
}
}
}
}
fun BaseSimpleActivity.emptyTheRecycleBin() {
deleteRecordings(getAllRecordings(trashed = true)) {}
}

View File

@ -1,20 +1,28 @@
package com.simplemobiletools.voicerecorder.extensions package com.simplemobiletools.voicerecorder.extensions
import android.annotation.SuppressLint
import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetManager
import android.content.ComponentName import android.content.ComponentName
import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.database.Cursor
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Bundle
import android.os.Environment import android.os.Environment
import com.simplemobiletools.commons.extensions.internalStoragePath import android.provider.MediaStore
import com.simplemobiletools.commons.helpers.isQPlus import android.provider.MediaStore.Audio.Media
import com.simplemobiletools.commons.extensions.*
import com.simplemobiletools.commons.helpers.*
import com.simplemobiletools.voicerecorder.R import com.simplemobiletools.voicerecorder.R
import com.simplemobiletools.voicerecorder.helpers.Config import com.simplemobiletools.voicerecorder.helpers.*
import com.simplemobiletools.voicerecorder.helpers.IS_RECORDING import com.simplemobiletools.voicerecorder.models.Recording
import com.simplemobiletools.voicerecorder.helpers.MyWidgetRecordDisplayProvider import java.io.File
import com.simplemobiletools.voicerecorder.helpers.TOGGLE_WIDGET_UI import kotlin.math.roundToLong
val Context.config: Config get() = Config.newInstance(applicationContext) val Context.config: Config get() = Config.newInstance(applicationContext)
@ -51,3 +59,182 @@ fun Context.getDefaultRecordingsRelativePath(): String {
getString(R.string.app_name) getString(R.string.app_name)
} }
} }
@SuppressLint("InlinedApi")
fun Context.getNewMediaStoreRecordings(trashed: Boolean = false): ArrayList<Recording> {
val recordings = ArrayList<Recording>()
val uri = Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
val projection = arrayOf(
Media._ID,
Media.DISPLAY_NAME,
Media.DATE_ADDED,
Media.DURATION,
Media.SIZE
)
val bundle = Bundle().apply {
putStringArray(ContentResolver.QUERY_ARG_SORT_COLUMNS, arrayOf(Media.DATE_ADDED))
putInt(ContentResolver.QUERY_ARG_SORT_DIRECTION, ContentResolver.QUERY_SORT_DIRECTION_DESCENDING)
putString(ContentResolver.QUERY_ARG_SQL_SELECTION, "${Media.OWNER_PACKAGE_NAME} = ?")
putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf(packageName))
if (config.useRecycleBin) {
val trashedValue = if (trashed) MediaStore.MATCH_ONLY else MediaStore.MATCH_EXCLUDE
putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, trashedValue)
}
}
queryCursor(uri, projection, bundle, true) { cursor ->
val recording = readRecordingFromCursor(cursor)
recordings.add(recording)
}
return recordings
}
@SuppressLint("InlinedApi")
fun Context.getMediaStoreRecordings(trashed: Boolean = false): ArrayList<Recording> {
val recordings = ArrayList<Recording>()
val uri = Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
val projection = arrayOf(
Media._ID,
Media.DISPLAY_NAME,
Media.DATE_ADDED,
Media.DURATION,
Media.SIZE
)
var selection = "${Media.OWNER_PACKAGE_NAME} = ?"
var selectionArgs = arrayOf(packageName)
val sortOrder = "${Media.DATE_ADDED} DESC"
if (config.useRecycleBin) {
val trashedValue = if (trashed) 1 else 0
selection += " AND ${Media.IS_TRASHED} = ?"
selectionArgs = selectionArgs.plus(trashedValue.toString())
}
queryCursor(uri, projection, selection, selectionArgs, sortOrder, true) { cursor ->
val recording = readRecordingFromCursor(cursor)
recordings.add(recording)
}
return recordings
}
fun Context.getLegacyRecordings(trashed: Boolean = false): ArrayList<Recording> {
val recordings = ArrayList<Recording>()
val folder = if (trashed) {
trashFolder
} else {
config.saveRecordingsFolder
}
val files = File(folder).listFiles() ?: return recordings
files.filter { it.isAudioFast() }.forEach {
val id = it.hashCode()
val title = it.name
val path = it.absolutePath
val timestamp = (it.lastModified() / 1000).toInt()
val duration = getDuration(it.absolutePath) ?: 0
val size = it.length().toInt()
val recording = Recording(id, title, path, timestamp, duration, size)
recordings.add(recording)
}
return recordings
}
fun Context.getSAFRecordings(trashed: Boolean = false): ArrayList<Recording> {
val recordings = ArrayList<Recording>()
val folder = if (trashed) {
trashFolder
} else {
config.saveRecordingsFolder
}
val files = getDocumentSdk30(folder)?.listFiles() ?: return recordings
files.filter { it.type?.startsWith("audio") == true && !it.name.isNullOrEmpty() }.forEach {
val id = it.hashCode()
val title = it.name!!
val path = it.uri.toString()
val timestamp = (it.lastModified() / 1000).toInt()
val duration = getDurationFromUri(it.uri)
val size = it.length().toInt()
val recording = Recording(id, title, path, timestamp, duration.toInt(), size)
recordings.add(recording)
}
recordings.sortByDescending { it.timestamp }
return recordings
}
fun Context.getAllRecordings(trashed: Boolean = false): ArrayList<Recording> {
val recordings = ArrayList<Recording>()
return when {
isRPlus() -> {
recordings.addAll(getNewMediaStoreRecordings(trashed))
recordings.addAll(getSAFRecordings(trashed))
recordings
}
isQPlus() -> {
recordings.addAll(getMediaStoreRecordings(trashed))
recordings.addAll(getLegacyRecordings(trashed))
recordings
}
else -> {
recordings.addAll(getLegacyRecordings(trashed))
recordings
}
}
}
val Context.trashFolder
get() = "${config.saveRecordingsFolder}/.trash"
fun Context.getOrCreateTrashFolder(): String {
val folder = File(trashFolder)
if (!folder.exists()) {
folder.mkdir()
}
return trashFolder
}
private fun Context.readRecordingFromCursor(cursor: Cursor): Recording {
val id = cursor.getIntValue(Media._ID)
val title = cursor.getStringValue(Media.DISPLAY_NAME)
val timestamp = cursor.getIntValue(Media.DATE_ADDED)
var duration = cursor.getLongValue(Media.DURATION) / 1000
var size = cursor.getIntValue(Media.SIZE)
if (duration == 0L) {
duration = getDurationFromUri(getAudioFileContentUri(id.toLong()))
}
if (size == 0) {
size = getSizeFromUri(id.toLong())
}
return Recording(id, title, "", timestamp, duration.toInt(), size)
}
private fun Context.getSizeFromUri(id: Long): Int {
val recordingUri = getAudioFileContentUri(id)
return try {
contentResolver.openInputStream(recordingUri)?.available() ?: 0
} catch (e: Exception) {
0
}
}
private fun Context.getDurationFromUri(uri: Uri): Long {
return try {
val retriever = MediaMetadataRetriever()
retriever.setDataSource(this, uri)
val time = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)!!
(time.toLong() / 1000.toDouble()).roundToLong()
} catch (e: Exception) {
0L
}
}

View File

@ -1,28 +1,23 @@
package com.simplemobiletools.voicerecorder.fragments package com.simplemobiletools.voicerecorder.fragments
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.media.AudioManager import android.media.AudioManager
import android.media.MediaMetadataRetriever
import android.media.MediaPlayer import android.media.MediaPlayer
import android.net.Uri import android.net.Uri
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.os.PowerManager import android.os.PowerManager
import android.provider.DocumentsContract import android.provider.DocumentsContract
import android.provider.MediaStore
import android.provider.MediaStore.Audio.Media
import android.util.AttributeSet import android.util.AttributeSet
import android.widget.SeekBar import android.widget.SeekBar
import com.simplemobiletools.commons.extensions.* import com.simplemobiletools.commons.extensions.*
import com.simplemobiletools.commons.helpers.ensureBackgroundThread
import com.simplemobiletools.commons.helpers.isQPlus import com.simplemobiletools.commons.helpers.isQPlus
import com.simplemobiletools.commons.helpers.isRPlus
import com.simplemobiletools.voicerecorder.R import com.simplemobiletools.voicerecorder.R
import com.simplemobiletools.voicerecorder.activities.SimpleActivity import com.simplemobiletools.voicerecorder.activities.SimpleActivity
import com.simplemobiletools.voicerecorder.adapters.RecordingsAdapter import com.simplemobiletools.voicerecorder.adapters.RecordingsAdapter
import com.simplemobiletools.voicerecorder.extensions.config import com.simplemobiletools.voicerecorder.extensions.config
import com.simplemobiletools.voicerecorder.extensions.getAllRecordings
import com.simplemobiletools.voicerecorder.helpers.getAudioFileContentUri import com.simplemobiletools.voicerecorder.helpers.getAudioFileContentUri
import com.simplemobiletools.voicerecorder.interfaces.RefreshRecordingsListener import com.simplemobiletools.voicerecorder.interfaces.RefreshRecordingsListener
import com.simplemobiletools.voicerecorder.models.Events import com.simplemobiletools.voicerecorder.models.Events
@ -31,9 +26,9 @@ import kotlinx.android.synthetic.main.fragment_player.view.*
import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode import org.greenrobot.eventbus.ThreadMode
import java.io.File import java.util.Stack
import java.util.* import java.util.Timer
import kotlin.math.roundToLong import java.util.TimerTask
class PlayerFragment(context: Context, attributeSet: AttributeSet) : MyViewPagerFragment(context, attributeSet), RefreshRecordingsListener { class PlayerFragment(context: Context, attributeSet: AttributeSet) : MyViewPagerFragment(context, attributeSet), RefreshRecordingsListener {
private val FAST_FORWARD_SKIP_MS = 10000 private val FAST_FORWARD_SKIP_MS = 10000
@ -45,18 +40,19 @@ class PlayerFragment(context: Context, attributeSet: AttributeSet) : MyViewPager
private var lastSearchQuery = "" private var lastSearchQuery = ""
private var bus: EventBus? = null private var bus: EventBus? = null
private var prevSavePath = "" private var prevSavePath = ""
private var prevRecycleBinState = context.config.useRecycleBin
private var playOnPreparation = true private var playOnPreparation = true
override fun onResume() { override fun onResume() {
setupColors() setupColors()
if (prevSavePath.isNotEmpty() && context!!.config.saveRecordingsFolder != prevSavePath) { if (prevSavePath.isNotEmpty() && context!!.config.saveRecordingsFolder != prevSavePath || context.config.useRecycleBin != prevRecycleBinState) {
itemsIgnoringSearch = getRecordings() itemsIgnoringSearch = getRecordings()
setupAdapter(itemsIgnoringSearch) setupAdapter(itemsIgnoringSearch)
} else { } else {
getRecordingsAdapter()?.updateTextColor(context.getProperTextColor()) getRecordingsAdapter()?.updateTextColor(context.getProperTextColor())
} }
storePrevPath() storePrevState()
} }
override fun onDestroy() { override fun onDestroy() {
@ -78,7 +74,7 @@ class PlayerFragment(context: Context, attributeSet: AttributeSet) : MyViewPager
setupAdapter(itemsIgnoringSearch) setupAdapter(itemsIgnoringSearch)
initMediaPlayer() initMediaPlayer()
setupViews() setupViews()
storePrevPath() storePrevState()
} }
private fun setupViews() { private fun setupViews() {
@ -141,164 +137,49 @@ class PlayerFragment(context: Context, attributeSet: AttributeSet) : MyViewPager
} }
private fun setupAdapter(recordings: ArrayList<Recording>) { private fun setupAdapter(recordings: ArrayList<Recording>) {
ensureBackgroundThread { recordings_fastscroller.beVisibleIf(recordings.isNotEmpty())
Handler(Looper.getMainLooper()).post { recordings_placeholder.beVisibleIf(recordings.isEmpty())
recordings_fastscroller.beVisibleIf(recordings.isNotEmpty()) if (recordings.isEmpty()) {
recordings_placeholder.beVisibleIf(recordings.isEmpty()) val stringId = if (lastSearchQuery.isEmpty()) {
if (recordings.isEmpty()) { if (isQPlus()) {
val stringId = if (lastSearchQuery.isEmpty()) { R.string.no_recordings_found
if (isQPlus()) {
R.string.no_recordings_found
} else {
R.string.no_recordings_in_folder_found
}
} else {
R.string.no_items_found
}
recordings_placeholder.text = context.getString(stringId)
resetProgress(null)
player?.stop()
}
val adapter = getRecordingsAdapter()
if (adapter == null) {
RecordingsAdapter(context as SimpleActivity, recordings, this, recordings_list) {
playRecording(it as Recording, true)
if (playedRecordingIDs.isEmpty() || playedRecordingIDs.peek() != it.id) {
playedRecordingIDs.push(it.id)
}
}.apply {
recordings_list.adapter = this
}
if (context.areSystemAnimationsEnabled) {
recordings_list.scheduleLayoutAnimation()
}
} else { } else {
adapter.updateItems(recordings) R.string.no_recordings_in_folder_found
} }
} else {
R.string.no_items_found
} }
recordings_placeholder.text = context.getString(stringId)
resetProgress(null)
player?.stop()
}
val adapter = getRecordingsAdapter()
if (adapter == null) {
RecordingsAdapter(context as SimpleActivity, recordings, this, recordings_list) {
playRecording(it as Recording, true)
if (playedRecordingIDs.isEmpty() || playedRecordingIDs.peek() != it.id) {
playedRecordingIDs.push(it.id)
}
}.apply {
recordings_list.adapter = this
}
if (context.areSystemAnimationsEnabled) {
recordings_list.scheduleLayoutAnimation()
}
} else {
adapter.updateItems(recordings)
} }
} }
private fun getRecordings(): ArrayList<Recording> { private fun getRecordings(): ArrayList<Recording> {
val recordings = ArrayList<Recording>() return context.getAllRecordings().apply {
return when {
isRPlus() -> {
recordings.addAll(getMediaStoreRecordings())
recordings.addAll(getSAFRecordings())
recordings
}
isQPlus() -> {
recordings.addAll(getMediaStoreRecordings())
recordings.addAll(getLegacyRecordings())
recordings
}
else -> {
recordings.addAll(getLegacyRecordings())
recordings
}
}.apply {
sortByDescending { it.timestamp } sortByDescending { it.timestamp }
} }
} }
@SuppressLint("InlinedApi")
private fun getMediaStoreRecordings(): ArrayList<Recording> {
val recordings = ArrayList<Recording>()
val uri = Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
val projection = arrayOf(
Media._ID,
Media.DISPLAY_NAME,
Media.DATE_ADDED,
Media.DURATION,
Media.SIZE
)
val selection = "${Media.OWNER_PACKAGE_NAME} = ?"
val selectionArgs = arrayOf(context.packageName)
val sortOrder = "${Media.DATE_ADDED} DESC"
context.queryCursor(uri, projection, selection, selectionArgs, sortOrder, true) { cursor ->
val id = cursor.getIntValue(Media._ID)
val title = cursor.getStringValue(Media.DISPLAY_NAME)
val timestamp = cursor.getIntValue(Media.DATE_ADDED)
var duration = cursor.getLongValue(Media.DURATION) / 1000
var size = cursor.getIntValue(Media.SIZE)
if (duration == 0L) {
duration = getDurationFromUri(getAudioFileContentUri(id.toLong()))
}
if (size == 0) {
size = getSizeFromUri(id.toLong())
}
val recording = Recording(id, title, "", timestamp, duration.toInt(), size)
recordings.add(recording)
}
return recordings
}
private fun getLegacyRecordings(): ArrayList<Recording> {
val recordings = ArrayList<Recording>()
val files = File(context.config.saveRecordingsFolder).listFiles() ?: return recordings
files.filter { it.isAudioFast() }.forEach {
val id = it.hashCode()
val title = it.name
val path = it.absolutePath
val timestamp = (it.lastModified() / 1000).toInt()
val duration = context.getDuration(it.absolutePath) ?: 0
val size = it.length().toInt()
val recording = Recording(id, title, path, timestamp, duration, size)
recordings.add(recording)
}
return recordings
}
private fun getSAFRecordings(): ArrayList<Recording> {
val recordings = ArrayList<Recording>()
val files = context.getDocumentSdk30(context.config.saveRecordingsFolder)?.listFiles() ?: return recordings
files.filter { it.type?.startsWith("audio") == true && !it.name.isNullOrEmpty() }.forEach {
val id = it.hashCode()
val title = it.name!!
val path = it.uri.toString()
val timestamp = (it.lastModified() / 1000).toInt()
val duration = getDurationFromUri(it.uri)
val size = it.length().toInt()
val recording = Recording(id, title, path, timestamp, duration.toInt(), size)
recordings.add(recording)
}
recordings.sortByDescending { it.timestamp }
return recordings
}
private fun getDurationFromUri(uri: Uri): Long {
return try {
val retriever = MediaMetadataRetriever()
retriever.setDataSource(context, uri)
val time = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)!!
(time.toLong() / 1000.toDouble()).roundToLong()
} catch (e: Exception) {
0L
}
}
private fun getSizeFromUri(id: Long): Int {
val recordingUri = getAudioFileContentUri(id)
return try {
context.contentResolver.openInputStream(recordingUri)?.available() ?: 0
} catch (e: Exception) {
0
}
}
private fun initMediaPlayer() { private fun initMediaPlayer() {
player = MediaPlayer().apply { player = MediaPlayer().apply {
setWakeMode(context, PowerManager.PARTIAL_WAKE_LOCK) setWakeMode(context, PowerManager.PARTIAL_WAKE_LOCK)
@ -336,9 +217,11 @@ class PlayerFragment(context: Context, attributeSet: AttributeSet) : MyViewPager
DocumentsContract.isDocumentUri(context, uri) -> { DocumentsContract.isDocumentUri(context, uri) -> {
setDataSource(context, uri) setDataSource(context, uri)
} }
recording.path.isEmpty() -> { recording.path.isEmpty() -> {
setDataSource(context, getAudioFileContentUri(recording.id.toLong())) setDataSource(context, getAudioFileContentUri(recording.id.toLong()))
} }
else -> { else -> {
setDataSource(recording.path) setDataSource(recording.path)
} }
@ -452,8 +335,9 @@ class PlayerFragment(context: Context, attributeSet: AttributeSet) : MyViewPager
private fun getRecordingsAdapter() = recordings_list.adapter as? RecordingsAdapter private fun getRecordingsAdapter() = recordings_list.adapter as? RecordingsAdapter
private fun storePrevPath() { private fun storePrevState() {
prevSavePath = context!!.config.saveRecordingsFolder prevSavePath = context!!.config.saveRecordingsFolder
prevRecycleBinState = context.config.useRecycleBin
} }
private fun setupColors() { private fun setupColors() {
@ -476,4 +360,9 @@ class PlayerFragment(context: Context, attributeSet: AttributeSet) : MyViewPager
fun recordingCompleted(event: Events.RecordingCompleted) { fun recordingCompleted(event: Events.RecordingCompleted) {
refreshRecordings() refreshRecordings()
} }
@Subscribe(threadMode = ThreadMode.MAIN)
fun recordingMovedToRecycleBin(event: Events.RecordingTrashUpdated) {
refreshRecordings()
}
} }

View File

@ -0,0 +1,119 @@
package com.simplemobiletools.voicerecorder.fragments
import android.content.Context
import android.util.AttributeSet
import com.simplemobiletools.commons.extensions.*
import com.simplemobiletools.voicerecorder.R
import com.simplemobiletools.voicerecorder.activities.SimpleActivity
import com.simplemobiletools.voicerecorder.adapters.TrashAdapter
import com.simplemobiletools.voicerecorder.extensions.config
import com.simplemobiletools.voicerecorder.extensions.getAllRecordings
import com.simplemobiletools.voicerecorder.interfaces.RefreshRecordingsListener
import com.simplemobiletools.voicerecorder.models.Events
import com.simplemobiletools.voicerecorder.models.Recording
import kotlinx.android.synthetic.main.fragment_trash.view.trash_fastscroller
import kotlinx.android.synthetic.main.fragment_trash.view.trash_holder
import kotlinx.android.synthetic.main.fragment_trash.view.trash_list
import kotlinx.android.synthetic.main.fragment_trash.view.trash_placeholder
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
class TrashFragment(context: Context, attributeSet: AttributeSet) : MyViewPagerFragment(context, attributeSet), RefreshRecordingsListener {
private var itemsIgnoringSearch = ArrayList<Recording>()
private var lastSearchQuery = ""
private var bus: EventBus? = null
private var prevSavePath = ""
override fun onResume() {
setupColors()
if (prevSavePath.isNotEmpty() && context!!.config.saveRecordingsFolder != prevSavePath) {
itemsIgnoringSearch = getRecordings()
setupAdapter(itemsIgnoringSearch)
} else {
getRecordingsAdapter()?.updateTextColor(context.getProperTextColor())
}
storePrevPath()
}
override fun onDestroy() {
bus?.unregister(this)
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
bus = EventBus.getDefault()
bus!!.register(this)
setupColors()
itemsIgnoringSearch = getRecordings()
setupAdapter(itemsIgnoringSearch)
storePrevPath()
}
override fun refreshRecordings() {
itemsIgnoringSearch = getRecordings()
setupAdapter(itemsIgnoringSearch)
}
override fun playRecording(recording: Recording, playOnPrepared: Boolean) {}
private fun setupAdapter(recordings: ArrayList<Recording>) {
trash_fastscroller.beVisibleIf(recordings.isNotEmpty())
trash_placeholder.beVisibleIf(recordings.isEmpty())
if (recordings.isEmpty()) {
val stringId = if (lastSearchQuery.isEmpty()) {
R.string.recycle_bin_empty
} else {
R.string.no_items_found
}
trash_placeholder.text = context.getString(stringId)
}
val adapter = getRecordingsAdapter()
if (adapter == null) {
TrashAdapter(context as SimpleActivity, recordings, this, trash_list).apply {
trash_list.adapter = this
}
if (context.areSystemAnimationsEnabled) {
trash_list.scheduleLayoutAnimation()
}
} else {
adapter.updateItems(recordings)
}
}
private fun getRecordings(): ArrayList<Recording> {
return context.getAllRecordings(trashed = true).apply {
sortByDescending { it.timestamp }
}
}
fun onSearchTextChanged(text: String) {
lastSearchQuery = text
val filtered = itemsIgnoringSearch.filter { it.title.contains(text, true) }.toMutableList() as ArrayList<Recording>
setupAdapter(filtered)
}
private fun getRecordingsAdapter() = trash_list.adapter as? TrashAdapter
private fun storePrevPath() {
prevSavePath = context!!.config.saveRecordingsFolder
}
private fun setupColors() {
val properPrimaryColor = context.getProperPrimaryColor()
trash_fastscroller.updateColors(properPrimaryColor)
context.updateTextColors(trash_holder)
}
fun finishActMode() = getRecordingsAdapter()?.finishActMode()
@Subscribe(threadMode = ThreadMode.MAIN)
fun recordingMovedToRecycleBin(event: Events.RecordingTrashUpdated) {
refreshRecordings()
}
}

View File

@ -72,4 +72,12 @@ class Config(context: Context) : BaseConfig(context) {
EXTENSION_OGG -> MediaRecorder.AudioEncoder.OPUS EXTENSION_OGG -> MediaRecorder.AudioEncoder.OPUS
else -> MediaRecorder.AudioEncoder.AAC else -> MediaRecorder.AudioEncoder.AAC
} }
var useRecycleBin: Boolean
get() = prefs.getBoolean(USE_RECYCLE_BIN, true)
set(useRecycleBin) = prefs.edit().putBoolean(USE_RECYCLE_BIN, useRecycleBin).apply()
var lastRecycleBinCheck: Long
get() = prefs.getLong(LAST_RECYCLE_BIN_CHECK, 0L)
set(lastRecycleBinCheck) = prefs.edit().putLong(LAST_RECYCLE_BIN_CHECK, lastRecycleBinCheck).apply()
} }

View File

@ -36,6 +36,8 @@ const val EXTENSION = "extension"
const val AUDIO_SOURCE = "audio_source" const val AUDIO_SOURCE = "audio_source"
const val BITRATE = "bitrate" const val BITRATE = "bitrate"
const val RECORD_AFTER_LAUNCH = "record_after_launch" const val RECORD_AFTER_LAUNCH = "record_after_launch"
const val USE_RECYCLE_BIN = "use_recycle_bin"
const val LAST_RECYCLE_BIN_CHECK = "last_recycle_bin_check"
@SuppressLint("InlinedApi") @SuppressLint("InlinedApi")
fun getAudioFileContentUri(id: Long): Uri { fun getAudioFileContentUri(id: Long): Uri {

View File

@ -7,5 +7,6 @@ class Events {
class RecordingStatus internal constructor(val status: Int) class RecordingStatus internal constructor(val status: Int)
class RecordingAmplitude internal constructor(val amplitude: Int) class RecordingAmplitude internal constructor(val amplitude: Int)
class RecordingCompleted internal constructor() class RecordingCompleted internal constructor()
class RecordingTrashUpdated internal constructor()
class RecordingSaved internal constructor(val uri: Uri?) class RecordingSaved internal constructor(val uri: Uri?)
} }

View File

@ -268,6 +268,55 @@
tools:text="128 kbps" /> tools:text="128 kbps" />
</RelativeLayout> </RelativeLayout>
<include
android:id="@+id/settings_general_settings_divider"
layout="@layout/divider" />
<TextView
android:id="@+id/settings_recycle_bin_label"
style="@style/SettingsSectionLabelStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/recycle_bin" />
<RelativeLayout
android:id="@+id/settings_use_recycle_bin_holder"
style="@style/SettingsHolderCheckboxStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.simplemobiletools.commons.views.MyAppCompatCheckbox
android:id="@+id/settings_use_recycle_bin"
style="@style/SettingsCheckboxStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/move_items_into_recycle_bin" />
</RelativeLayout>
<RelativeLayout
android:id="@+id/settings_empty_recycle_bin_holder"
style="@style/SettingsHolderTextViewStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.simplemobiletools.commons.views.MyTextView
android:id="@+id/settings_empty_recycle_bin_label"
style="@style/SettingsTextLabelStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/empty_recycle_bin" />
<com.simplemobiletools.commons.views.MyTextView
android:id="@+id/settings_empty_recycle_bin_size"
style="@style/SettingsTextValueStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/settings_empty_recycle_bin_label"
tools:text="0 B" />
</RelativeLayout>
</LinearLayout> </LinearLayout>
</androidx.core.widget.NestedScrollView> </androidx.core.widget.NestedScrollView>
</androidx.coordinatorlayout.widget.CoordinatorLayout> </androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/delete_remember_holder"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingLeft="@dimen/big_margin"
android:paddingTop="@dimen/big_margin"
android:paddingRight="@dimen/big_margin">
<com.simplemobiletools.commons.views.MyTextView
android:id="@+id/delete_remember_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="@dimen/small_margin"
android:paddingBottom="@dimen/activity_margin"
android:text="@string/delete_recordings_confirmation"
android:textSize="@dimen/bigger_text_size" />
<com.simplemobiletools.commons.views.MyAppCompatCheckbox
android:id="@+id/skip_the_recycle_bin_checkbox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/delete_remember_title"
android:text="@string/skip_the_recycle_bin" />
</RelativeLayout>

View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<com.simplemobiletools.voicerecorder.fragments.TrashFragment xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/trash_holder"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.simplemobiletools.commons.views.MyTextView
android:id="@+id/trash_placeholder"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:alpha="0.8"
android:gravity="center"
android:lineSpacingExtra="@dimen/small_margin"
android:padding="@dimen/activity_margin"
android:text="@string/recycle_bin_empty"
android:textSize="@dimen/bigger_text_size"
android:textStyle="italic"
android:visibility="visible" />
<com.qtalk.recyclerviewfastscroller.RecyclerViewFastScroller
android:id="@+id/trash_fastscroller"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintTop_toTopOf="parent">
<com.simplemobiletools.commons.views.MyRecyclerView
android:id="@+id/trash_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:layoutAnimation="@anim/layout_animation"
android:scrollbars="none"
app:layoutManager="com.simplemobiletools.commons.views.MyLinearLayoutManager" />
</com.qtalk.recyclerviewfastscroller.RecyclerViewFastScroller>
</com.simplemobiletools.voicerecorder.fragments.TrashFragment>

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:ignore="AppCompatResource,AlwaysShowAction">
<item
android:id="@+id/cab_delete"
android:icon="@drawable/ic_delete_vector"
android:showAsAction="always"
android:title="@string/delete" />
<item
android:id="@+id/cab_restore"
android:showAsAction="never"
android:title="@string/restore_selected_files"
app:showAsAction="never" />
<item
android:id="@+id/cab_select_all"
android:icon="@drawable/ic_select_all_vector"
android:title="@string/select_all"
app:showAsAction="ifRoom" />
</menu>