mirror of
https://gitlab.linphone.org/BC/public/linphone-android.git
synced 2026-01-17 11:28:06 +00:00
Started call recordings list
This commit is contained in:
parent
4d561a4635
commit
a557875ce8
21 changed files with 805 additions and 88 deletions
|
|
@ -28,6 +28,7 @@ import android.content.Intent
|
|||
import android.graphics.RenderEffect
|
||||
import android.graphics.Shader
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.view.View
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.content.ContextCompat
|
||||
|
|
@ -92,5 +93,9 @@ class Api31Compatibility {
|
|||
Log.e("$TAG Can't start service as foreground! $e")
|
||||
}
|
||||
}
|
||||
|
||||
fun getRecordingsDirectory(): String {
|
||||
return Environment.DIRECTORY_RECORDINGS
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import android.app.Service
|
|||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import org.linphone.core.tools.Log
|
||||
|
|
@ -174,5 +175,12 @@ class Compatibility {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getRecordingsDirectory(): String {
|
||||
if (Version.sdkAboveOrEqual(Version.API31_ANDROID_12)) {
|
||||
return Api31Compatibility.getRecordingsDirectory()
|
||||
}
|
||||
return Environment.DIRECTORY_PODCASTS
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -186,7 +186,7 @@ class CorePreferences @UiThread constructor(private val context: Context) {
|
|||
|
||||
@get:WorkerThread
|
||||
val disableCallRecordings: Boolean
|
||||
get() = config.getBool("ui", "disable_call_recordings_feature", true) // TODO FIXME: not implemented yet
|
||||
get() = config.getBool("ui", "disable_call_recordings_feature", false)
|
||||
|
||||
@get:WorkerThread
|
||||
val oneAccountMax: Boolean
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ class EditContactFragment : SlidingPaneChildFragment() {
|
|||
Log.i("$TAG Picture picked [$uri]")
|
||||
val localFileName = FileUtils.getFileStoragePath(
|
||||
viewModel.getPictureFileName(),
|
||||
true,
|
||||
isImage = true,
|
||||
overrideExisting = true
|
||||
)
|
||||
lifecycleScope.launch {
|
||||
|
|
|
|||
|
|
@ -180,7 +180,7 @@ class ContactNewOrEditViewModel @UiThread constructor() : GenericViewModel() {
|
|||
if (picture.contains(TEMP_PICTURE_NAME)) {
|
||||
val newFile = FileUtils.getFileStoragePath(
|
||||
getPictureFileName(),
|
||||
true,
|
||||
isImage = true,
|
||||
overrideExisting = true
|
||||
)
|
||||
val oldFile = Uri.parse(FileUtils.getProperFilePath(picture))
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.main.recordings
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.UiThread
|
||||
import org.linphone.databinding.RecordingsFragmentBinding
|
||||
import org.linphone.ui.main.fragment.GenericMainFragment
|
||||
|
||||
@UiThread
|
||||
class RecordingsFragment : GenericMainFragment() {
|
||||
private lateinit var binding: RecordingsFragmentBinding
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
binding = RecordingsFragmentBinding.inflate(layoutInflater)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.lifecycleOwner = viewLifecycleOwner
|
||||
|
||||
binding.setBackClickListener {
|
||||
goBack()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.main.recordings.adapter
|
||||
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.databinding.DataBindingUtil
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.findViewTreeLifecycleOwner
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.linphone.R
|
||||
import org.linphone.databinding.RecordingListCellBinding
|
||||
import org.linphone.databinding.RecordingsListDecorationBinding
|
||||
import org.linphone.ui.main.recordings.model.RecordingModel
|
||||
import org.linphone.utils.Event
|
||||
import org.linphone.utils.HeaderAdapter
|
||||
|
||||
class RecordingsListAdapter :
|
||||
ListAdapter<RecordingModel, RecyclerView.ViewHolder>(
|
||||
RecordingDiffCallback()
|
||||
),
|
||||
HeaderAdapter {
|
||||
var selectedAdapterPosition = -1
|
||||
|
||||
val recordingLongClickedEvent: MutableLiveData<Event<RecordingModel>> by lazy {
|
||||
MutableLiveData<Event<RecordingModel>>()
|
||||
}
|
||||
|
||||
override fun displayHeaderForPosition(position: Int): Boolean {
|
||||
if (position == 0) return true
|
||||
|
||||
val previous = getItem(position - 1)
|
||||
val item = getItem(position)
|
||||
return previous.month != item.month
|
||||
}
|
||||
|
||||
override fun getHeaderViewForPosition(context: Context, position: Int): View {
|
||||
val binding = RecordingsListDecorationBinding.inflate(LayoutInflater.from(context))
|
||||
val item = getItem(position)
|
||||
binding.header.text = item.month
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
val binding: RecordingListCellBinding = DataBindingUtil.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
R.layout.recording_list_cell,
|
||||
parent,
|
||||
false
|
||||
)
|
||||
val viewHolder = ViewHolder(binding)
|
||||
binding.apply {
|
||||
lifecycleOwner = parent.findViewTreeLifecycleOwner()
|
||||
|
||||
setOnLongClickListener {
|
||||
selectedAdapterPosition = viewHolder.bindingAdapterPosition
|
||||
root.isSelected = true
|
||||
recordingLongClickedEvent.value = Event(model!!)
|
||||
true
|
||||
}
|
||||
}
|
||||
return viewHolder
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
(holder as ViewHolder).bind(getItem(position))
|
||||
}
|
||||
|
||||
fun resetSelection() {
|
||||
notifyItemChanged(selectedAdapterPosition)
|
||||
selectedAdapterPosition = -1
|
||||
}
|
||||
|
||||
inner class ViewHolder(
|
||||
val binding: RecordingListCellBinding
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
@UiThread
|
||||
fun bind(recordingModel: RecordingModel) {
|
||||
with(binding) {
|
||||
model = recordingModel
|
||||
|
||||
binding.root.isSelected = bindingAdapterPosition == selectedAdapterPosition
|
||||
|
||||
executePendingBindings()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class RecordingDiffCallback : DiffUtil.ItemCallback<RecordingModel>() {
|
||||
override fun areItemsTheSame(oldItem: RecordingModel, newItem: RecordingModel): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: RecordingModel, newItem: RecordingModel): Boolean {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.main.recordings.fragment
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
import kotlinx.coroutines.launch
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.databinding.RecordingsFragmentBinding
|
||||
import org.linphone.ui.main.fragment.GenericMainFragment
|
||||
import org.linphone.ui.main.recordings.adapter.RecordingsListAdapter
|
||||
import org.linphone.ui.main.recordings.viewmodel.RecordingsListViewModel
|
||||
import org.linphone.utils.RecyclerViewHeaderDecoration
|
||||
import org.linphone.utils.hideKeyboard
|
||||
import org.linphone.utils.showKeyboard
|
||||
|
||||
@UiThread
|
||||
class RecordingsFragment : GenericMainFragment() {
|
||||
companion object {
|
||||
private const val TAG = "[Recordings List Fragment]"
|
||||
}
|
||||
|
||||
private lateinit var binding: RecordingsFragmentBinding
|
||||
|
||||
private lateinit var listViewModel: RecordingsListViewModel
|
||||
|
||||
private lateinit var adapter: RecordingsListAdapter
|
||||
|
||||
private var bottomSheetDialog: BottomSheetDialogFragment? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
adapter = RecordingsListAdapter()
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
binding = RecordingsFragmentBinding.inflate(layoutInflater)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
listViewModel = ViewModelProvider(this)[RecordingsListViewModel::class.java]
|
||||
|
||||
binding.lifecycleOwner = viewLifecycleOwner
|
||||
binding.viewModel = listViewModel
|
||||
|
||||
binding.setBackClickListener {
|
||||
goBack()
|
||||
}
|
||||
|
||||
binding.recordingsList.setHasFixedSize(true)
|
||||
binding.recordingsList.layoutManager = LinearLayoutManager(requireContext())
|
||||
val headerItemDecoration = RecyclerViewHeaderDecoration(requireContext(), adapter)
|
||||
binding.recordingsList.addItemDecoration(headerItemDecoration)
|
||||
|
||||
if (binding.recordingsList.adapter != adapter) {
|
||||
binding.recordingsList.adapter = adapter
|
||||
}
|
||||
|
||||
listViewModel.recordings.observe(viewLifecycleOwner) {
|
||||
val count = it.size
|
||||
adapter.submitList(it)
|
||||
Log.i("$TAG Recordings list ready with [$count] items")
|
||||
listViewModel.fetchInProgress.value = false
|
||||
}
|
||||
|
||||
listViewModel.searchFilter.observe(viewLifecycleOwner) { filter ->
|
||||
listViewModel.applyFilter(filter.trim())
|
||||
}
|
||||
|
||||
listViewModel.focusSearchBarEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { show ->
|
||||
if (show) {
|
||||
// To automatically open keyboard
|
||||
binding.search.showKeyboard()
|
||||
} else {
|
||||
binding.search.hideKeyboard()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
adapter.recordingLongClickedEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { model ->
|
||||
val modalBottomSheet = RecordingsMenuDialogFragment(
|
||||
{ // onDismiss
|
||||
adapter.resetSelection()
|
||||
},
|
||||
{ // onShare
|
||||
adapter.resetSelection()
|
||||
},
|
||||
{ // onExport
|
||||
adapter.resetSelection()
|
||||
},
|
||||
{ // onDelete
|
||||
Log.i("$TAG Deleting meeting [${model.filePath}]")
|
||||
lifecycleScope.launch {
|
||||
model.delete()
|
||||
}
|
||||
listViewModel.applyFilter(listViewModel.searchFilter.value.orEmpty())
|
||||
}
|
||||
)
|
||||
modalBottomSheet.show(parentFragmentManager, RecordingsMenuDialogFragment.TAG)
|
||||
bottomSheetDialog = modalBottomSheet
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
|
||||
bottomSheetDialog?.dismiss()
|
||||
bottomSheetDialog = null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2023 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.main.recordings.fragment
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.DialogInterface
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.UiThread
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
import org.linphone.databinding.RecordingsListLongPressMenuBinding
|
||||
|
||||
@UiThread
|
||||
class RecordingsMenuDialogFragment(
|
||||
private val onDismiss: (() -> Unit)? = null,
|
||||
private val onShare: (() -> Unit)? = null,
|
||||
private val onExport: (() -> Unit)? = null,
|
||||
private val onDelete: (() -> Unit)? = null
|
||||
) : BottomSheetDialogFragment() {
|
||||
companion object {
|
||||
const val TAG = "RecordingsMenuDialogFragment"
|
||||
}
|
||||
|
||||
override fun onCancel(dialog: DialogInterface) {
|
||||
onDismiss?.invoke()
|
||||
super.onCancel(dialog)
|
||||
}
|
||||
|
||||
override fun onDismiss(dialog: DialogInterface) {
|
||||
onDismiss?.invoke()
|
||||
super.onDismiss(dialog)
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val dialog = super.onCreateDialog(savedInstanceState) as BottomSheetDialog
|
||||
// Makes sure all menu entries are visible,
|
||||
// required for landscape mode (otherwise only first item is visible)
|
||||
dialog.behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
return dialog
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
val view = RecordingsListLongPressMenuBinding.inflate(layoutInflater)
|
||||
|
||||
view.setDeleteClickListener {
|
||||
onDelete?.invoke()
|
||||
dismiss()
|
||||
}
|
||||
|
||||
return view.root
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2024 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.main.recordings.model
|
||||
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.annotation.WorkerThread
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.core.Factory
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.utils.FileUtils
|
||||
import org.linphone.utils.LinphoneUtils
|
||||
import org.linphone.utils.TimestampUtils
|
||||
|
||||
class RecordingModel @WorkerThread constructor(val filePath: String, val fileName: String) {
|
||||
companion object {
|
||||
private const val TAG = "[Recording Model]"
|
||||
}
|
||||
|
||||
val displayName: String
|
||||
|
||||
val month: String
|
||||
|
||||
val dateTime: String
|
||||
|
||||
init {
|
||||
val withoutHeader = fileName.substring(LinphoneUtils.RECORDING_FILE_NAME_HEADER.length)
|
||||
val indexOfSeparator = withoutHeader.indexOf(
|
||||
LinphoneUtils.RECORDING_FILE_NAME_URI_TIMESTAMP_SEPARATOR
|
||||
)
|
||||
val sipUri = withoutHeader.substring(0, indexOfSeparator)
|
||||
val timestamp = withoutHeader.substring(
|
||||
indexOfSeparator + LinphoneUtils.RECORDING_FILE_NAME_URI_TIMESTAMP_SEPARATOR.length,
|
||||
withoutHeader.length - LinphoneUtils.RECORDING_FILE_EXTENSION.length
|
||||
)
|
||||
Log.i("$TAG Extract SIP URI [$sipUri] and timestamp [$timestamp] from file [$fileName]")
|
||||
|
||||
val parsedTimestamp = timestamp.toLong()
|
||||
month = TimestampUtils.month(parsedTimestamp, timestampInSecs = false)
|
||||
val date = TimestampUtils.toString(
|
||||
parsedTimestamp,
|
||||
timestampInSecs = false,
|
||||
onlyDate = true,
|
||||
shortDate = false
|
||||
)
|
||||
val time = TimestampUtils.timeToString(parsedTimestamp, timestampInSecs = false)
|
||||
dateTime = "$date - $time"
|
||||
|
||||
val sipAddress = Factory.instance().createAddress(sipUri)
|
||||
displayName = if (sipAddress != null) {
|
||||
val contact = coreContext.contactsManager.findContactByAddress(sipAddress)
|
||||
contact?.name ?: LinphoneUtils.getDisplayName(sipAddress)
|
||||
} else {
|
||||
sipUri
|
||||
}
|
||||
}
|
||||
|
||||
@UiThread
|
||||
suspend fun delete() {
|
||||
Log.i("$TAG Deleting call recording [$filePath]")
|
||||
FileUtils.deleteFile(filePath)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2024 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.main.recordings.viewmodel
|
||||
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.ui.GenericViewModel
|
||||
import org.linphone.ui.main.recordings.model.RecordingModel
|
||||
import org.linphone.utils.Event
|
||||
import org.linphone.utils.FileUtils
|
||||
|
||||
class RecordingsListViewModel @UiThread constructor() : GenericViewModel() {
|
||||
companion object {
|
||||
private const val TAG = "[Recordings List ViewModel]"
|
||||
}
|
||||
|
||||
val recordings = MutableLiveData<ArrayList<RecordingModel>>()
|
||||
|
||||
val searchBarVisible = MutableLiveData<Boolean>()
|
||||
|
||||
val searchFilter = MutableLiveData<String>()
|
||||
|
||||
val fetchInProgress = MutableLiveData<Boolean>()
|
||||
|
||||
val focusSearchBarEvent: MutableLiveData<Event<Boolean>> by lazy {
|
||||
MutableLiveData<Event<Boolean>>()
|
||||
}
|
||||
|
||||
init {
|
||||
searchBarVisible.value = false
|
||||
fetchInProgress.value = true
|
||||
|
||||
coreContext.postOnCoreThread {
|
||||
computeList("")
|
||||
}
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun openSearchBar() {
|
||||
searchBarVisible.value = true
|
||||
focusSearchBarEvent.value = Event(true)
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun closeSearchBar() {
|
||||
clearFilter()
|
||||
searchBarVisible.value = false
|
||||
focusSearchBarEvent.value = Event(false)
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun clearFilter() {
|
||||
searchFilter.value = ""
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun applyFilter(filter: String) {
|
||||
coreContext.postOnCoreThread {
|
||||
computeList(filter)
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun computeList(filter: String) {
|
||||
// TODO FIXME: use filter
|
||||
val list = arrayListOf<RecordingModel>()
|
||||
|
||||
// TODO FIXME: also load recordings from previous Linphone versions
|
||||
for (file in FileUtils.getFileStorageDir(isRecording = true).listFiles().orEmpty()) {
|
||||
val path = file.path
|
||||
val name = file.name
|
||||
Log.i("$TAG Found file $path")
|
||||
list.add(RecordingModel(path, name))
|
||||
}
|
||||
|
||||
list.sortBy {
|
||||
it.filePath // TODO FIXME
|
||||
}
|
||||
recordings.postValue(list)
|
||||
}
|
||||
}
|
||||
|
|
@ -146,9 +146,10 @@ class FileUtils {
|
|||
fun getFileStoragePath(
|
||||
fileName: String,
|
||||
isImage: Boolean = false,
|
||||
isRecording: Boolean = false,
|
||||
overrideExisting: Boolean = false
|
||||
): File {
|
||||
val path = getFileStorageDir(isImage)
|
||||
val path = getFileStorageDir(isPicture = isImage, isRecording = isRecording)
|
||||
var file = File(path, fileName)
|
||||
|
||||
if (!overrideExisting) {
|
||||
|
|
@ -246,7 +247,11 @@ class FileUtils {
|
|||
val localFile: File = if (copyToCache) {
|
||||
getFileStorageCacheDir(name, overrideExisting)
|
||||
} else {
|
||||
getFileStoragePath(name, isImage, overrideExisting)
|
||||
getFileStoragePath(
|
||||
name,
|
||||
isImage = isImage,
|
||||
overrideExisting = overrideExisting
|
||||
)
|
||||
}
|
||||
copyFile(uri, localFile)
|
||||
return@withContext localFile.absolutePath
|
||||
|
|
@ -455,7 +460,7 @@ class FileUtils {
|
|||
}
|
||||
|
||||
@AnyThread
|
||||
private fun getFileStorageDir(isPicture: Boolean = false): File {
|
||||
fun getFileStorageDir(isPicture: Boolean = false, isRecording: Boolean = false): File {
|
||||
var path: File? = null
|
||||
if (Environment.getExternalStorageState() == Environment.MEDIA_MOUNTED) {
|
||||
Log.w("$TAG External storage is mounted")
|
||||
|
|
@ -463,6 +468,9 @@ class FileUtils {
|
|||
if (isPicture) {
|
||||
Log.w("$TAG Using pictures directory instead of downloads")
|
||||
directory = Environment.DIRECTORY_PICTURES
|
||||
} else if (isRecording) {
|
||||
directory = Compatibility.getRecordingsDirectory()
|
||||
Log.w("$TAG Using [$directory] directory instead of downloads")
|
||||
}
|
||||
path = coreContext.context.getExternalFilesDir(directory)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,10 +23,6 @@ import androidx.annotation.AnyThread
|
|||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.IntegerRes
|
||||
import androidx.annotation.WorkerThread
|
||||
import java.text.DateFormat
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.R
|
||||
import org.linphone.core.Account
|
||||
|
|
@ -48,7 +44,10 @@ class LinphoneUtils {
|
|||
companion object {
|
||||
private const val TAG = "[Linphone Utils]"
|
||||
|
||||
private const val RECORDING_DATE_PATTERN = "dd-MM-yyyy-HH-mm-ss"
|
||||
const val RECORDING_FILE_NAME_HEADER = "call_recording_"
|
||||
const val RECORDING_FILE_NAME_URI_TIMESTAMP_SEPARATOR = "_on_"
|
||||
const val RECORDING_FILE_EXTENSION = ".mkv"
|
||||
|
||||
private const val CHAT_ROOM_ID_SEPARATOR = "#~#"
|
||||
|
||||
@WorkerThread
|
||||
|
|
@ -319,13 +318,8 @@ class LinphoneUtils {
|
|||
|
||||
@WorkerThread
|
||||
fun getRecordingFilePathForAddress(address: Address): String {
|
||||
val displayName = getDisplayName(address)
|
||||
val dateFormat: DateFormat = SimpleDateFormat(
|
||||
RECORDING_DATE_PATTERN,
|
||||
Locale.getDefault()
|
||||
)
|
||||
val fileName = "${displayName}_${dateFormat.format(Date())}.mkv"
|
||||
return FileUtils.getFileStoragePath(fileName).absolutePath
|
||||
val fileName = "${RECORDING_FILE_NAME_HEADER}${address.asStringUriOnly()}${RECORDING_FILE_NAME_URI_TIMESTAMP_SEPARATOR}${System.currentTimeMillis()}$RECORDING_FILE_EXTENSION"
|
||||
return FileUtils.getFileStoragePath(fileName, isRecording = true).absolutePath
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="256"
|
||||
android:viewportHeight="256">
|
||||
<path
|
||||
android:pathData="M168,16A72.07,72.07 0,0 0,96 88a73.29,73.29 0,0 0,0.63 9.42L27.12,192.22A15.93,15.93 0,0 0,28.71 213L43,227.29a15.93,15.93 0,0 0,20.78 1.59l94.81,-69.53A73.29,73.29 0,0 0,168 160a72,72 0,1 0,0 -144ZM224,88a55.72,55.72 0,0 1,-11.16 33.52L134.49,43.16A56,56 0,0 1,224 88ZM54.32,216 L40,201.68 102.14,117A72.37,72.37 0,0 0,139 153.86ZM112,88a55.67,55.67 0,0 1,11.16 -33.51l78.34,78.34A56,56 0,0 1,112 88ZM109.65,146.34a8,8 0,0 1,0 11.31l-8,8a8,8 0,1 1,-11.31 -11.31l8,-8A8,8 0,0 1,109.67 146.33Z"
|
||||
android:fillColor="#4e6074"/>
|
||||
</vector>
|
||||
|
|
@ -58,13 +58,6 @@
|
|||
app:constraint_referenced_ids="cancel_search, search, clear_field"
|
||||
android:visibility="@{viewModel.searchBarVisible ? View.VISIBLE : View.GONE, default=gone}" />
|
||||
|
||||
<androidx.constraintlayout.widget.Barrier
|
||||
android:id="@+id/top_bar_barrier"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:barrierDirection="bottom"
|
||||
app:constraint_referenced_ids="title, search" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/back"
|
||||
android:layout_width="wrap_content"
|
||||
|
|
|
|||
|
|
@ -167,7 +167,7 @@
|
|||
android:layout_marginStart="16dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:text="@string/recordings_title"
|
||||
android:drawableStart="@drawable/microphone_stage"
|
||||
android:drawableStart="@drawable/record_fill"
|
||||
android:drawableEnd="@drawable/caret_right"
|
||||
android:drawablePadding="8dp"
|
||||
android:visibility="@{viewModel.hideRecordings ? View.GONE : View.VISIBLE}"
|
||||
|
|
|
|||
92
app/src/main/res/layout/recording_list_cell.xml
Normal file
92
app/src/main/res/layout/recording_list_cell.xml
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<data>
|
||||
<import type="android.view.View" />
|
||||
<variable
|
||||
name="model"
|
||||
type="org.linphone.ui.main.recordings.model.RecordingModel" />
|
||||
<variable
|
||||
name="onLongClickListener"
|
||||
type="View.OnLongClickListener" />
|
||||
</data>
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="16dp">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:id="@+id/cardview"
|
||||
android:onLongClick="@{onLongClickListener}"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="5dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:layout_marginStart="5dp"
|
||||
android:layout_marginEnd="5dp"
|
||||
android:background="@drawable/primary_cell_r10_background"
|
||||
android:elevation="5dp"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent">
|
||||
|
||||
<androidx.appcompat.widget.AppCompatTextView
|
||||
style="@style/default_text_style_700"
|
||||
android:id="@+id/title"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginEnd="5dp"
|
||||
android:text="@{model.displayName, default=`John Doe`}"
|
||||
android:textSize="13sp"
|
||||
android:textColor="?attr/color_main2_600"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end"
|
||||
android:drawableStart="@drawable/phone"
|
||||
android:drawablePadding="8dp"
|
||||
android:drawableTint="?attr/color_main2_600"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@id/play_pause"/>
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/play_pause"
|
||||
android:layout_width="@dimen/icon_size"
|
||||
android:layout_height="@dimen/icon_size"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:src="@drawable/play_fill"
|
||||
app:layout_constraintTop_toTopOf="@id/title"
|
||||
app:layout_constraintBottom_toBottomOf="@id/title"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:tint="?attr/color_main1_500" />
|
||||
|
||||
<androidx.appcompat.widget.AppCompatTextView
|
||||
style="@style/default_text_style"
|
||||
android:id="@+id/time"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="10dp"
|
||||
android:layout_marginTop="3dp"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:text="@{model.dateTime, default=`15 Mai - 14h38`}"
|
||||
android:textSize="14sp"
|
||||
android:textColor="?attr/color_main2_500"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/title"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
</layout>
|
||||
|
|
@ -8,6 +8,9 @@
|
|||
<variable
|
||||
name="backClickListener"
|
||||
type="View.OnClickListener" />
|
||||
<variable
|
||||
name="viewModel"
|
||||
type="org.linphone.ui.main.recordings.viewmodel.RecordingsListViewModel" />
|
||||
</data>
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
|
|
@ -15,6 +18,12 @@
|
|||
android:layout_height="match_parent"
|
||||
android:background="?attr/color_main2_000">
|
||||
|
||||
<androidx.constraintlayout.widget.Group
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:constraint_referenced_ids="cancel_search, search, clear_field"
|
||||
android:visibility="@{viewModel.searchBarVisible ? View.VISIBLE : View.GONE, default=gone}" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/back"
|
||||
android:onClick="@{backClickListener}"
|
||||
|
|
@ -37,10 +46,101 @@
|
|||
android:layout_marginStart="10dp"
|
||||
android:layout_marginEnd="10dp"
|
||||
android:text="@string/recordings_title"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
android:visibility="@{viewModel.searchBarVisible ? View.GONE : View.VISIBLE}"
|
||||
app:layout_constraintEnd_toStartOf="@id/search_toggle"
|
||||
app:layout_constraintStart_toEndOf="@id/back"
|
||||
app:layout_constraintTop_toTopOf="parent"/>
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/search_toggle"
|
||||
android:onClick="@{() -> viewModel.openSearchBar()}"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="0dp"
|
||||
android:padding="15dp"
|
||||
android:src="@drawable/magnifying_glass"
|
||||
android:visibility="@{viewModel.searchBarVisible ? View.GONE : View.VISIBLE}"
|
||||
android:contentDescription="@string/content_description_open_filter"
|
||||
app:layout_constraintBottom_toBottomOf="@id/title"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="@id/title"
|
||||
app:tint="?attr/color_main1_500" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cancel_search"
|
||||
android:onClick="@{() -> viewModel.closeSearchBar()}"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="0dp"
|
||||
android:padding="15dp"
|
||||
android:src="@drawable/caret_left"
|
||||
android:contentDescription="@string/content_description_cancel_filter"
|
||||
app:layout_constraintBottom_toBottomOf="@id/search"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="@id/search"
|
||||
app:tint="?attr/color_main1_500" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
style="?attr/textInputFilledStyle"
|
||||
android:id="@+id/search"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="@dimen/top_bar_height"
|
||||
android:gravity="center_vertical"
|
||||
android:textColorHint="?attr/color_main2_400"
|
||||
app:hintEnabled="false"
|
||||
app:hintAnimationEnabled="false"
|
||||
app:hintTextColor="?attr/color_main2_400"
|
||||
app:boxStrokeWidth="0dp"
|
||||
app:boxStrokeWidthFocused="0dp"
|
||||
app:layout_constraintEnd_toStartOf="@id/clear_field"
|
||||
app:layout_constraintStart_toEndOf="@id/cancel_search"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:textCursorDrawable="@null"
|
||||
android:textSize="16sp"
|
||||
android:inputType="text"
|
||||
android:paddingVertical="1dp"
|
||||
android:text="@={viewModel.searchFilter}"
|
||||
android:background="@android:color/transparent" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/clear_field"
|
||||
android:onClick="@{() -> viewModel.clearFilter()}"
|
||||
android:enabled="@{viewModel.searchFilter.length() > 0}"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="6dp"
|
||||
android:layout_marginEnd="9dp"
|
||||
android:src="@drawable/x"
|
||||
android:contentDescription="@string/content_description_clear_filter"
|
||||
app:layout_constraintBottom_toBottomOf="@id/search"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="@id/search"
|
||||
app:tint="?attr/color_main1_500" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recordings_list"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginTop="@dimen/top_bar_height"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"/>
|
||||
|
||||
<com.google.android.material.progressindicator.CircularProgressIndicator
|
||||
android:id="@+id/fetch_in_progress"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:indeterminate="true"
|
||||
android:visibility="@{viewModel.fetchInProgress ? View.VISIBLE : View.GONE}"
|
||||
app:indicatorColor="@color/main1_500"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
</layout>
|
||||
23
app/src/main/res/layout/recordings_list_decoration.xml
Normal file
23
app/src/main/res/layout/recordings_list_decoration.xml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layout xmlns:tools="http://schemas.android.com/tools"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<data>
|
||||
<import type="android.view.View" />
|
||||
</data>
|
||||
|
||||
<androidx.appcompat.widget.AppCompatTextView
|
||||
style="@style/section_header_style"
|
||||
android:id="@+id/header"
|
||||
android:background="?attr/color_main2_000"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="20dp"
|
||||
android:paddingEnd="20dp"
|
||||
android:paddingTop="16dp"
|
||||
android:paddingBottom="16dp"
|
||||
android:text="October"
|
||||
android:gravity="center_vertical"
|
||||
tools:ignore="HardcodedText" />
|
||||
|
||||
</layout>
|
||||
33
app/src/main/res/layout/recordings_list_long_press_menu.xml
Normal file
33
app/src/main/res/layout/recordings_list_long_press_menu.xml
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layout xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<data>
|
||||
<import type="android.view.View" />
|
||||
<variable
|
||||
name="deleteClickListener"
|
||||
type="View.OnClickListener" />
|
||||
</data>
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/color_main2_200">
|
||||
|
||||
<androidx.appcompat.widget.AppCompatTextView
|
||||
android:id="@+id/delete"
|
||||
android:onClick="@{deleteClickListener}"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/menu_delete_selected_item"
|
||||
style="@style/context_menu_danger_action_label_style"
|
||||
android:background="@drawable/menu_item_background"
|
||||
android:layout_marginBottom="1dp"
|
||||
android:drawableStart="@drawable/trash_simple"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"/>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
</layout>
|
||||
|
|
@ -174,7 +174,7 @@
|
|||
|
||||
<fragment
|
||||
android:id="@+id/recordingsFragment"
|
||||
android:name="org.linphone.ui.main.recordings.RecordingsFragment"
|
||||
android:name="org.linphone.ui.main.recordings.fragment.RecordingsFragment"
|
||||
android:label="RecordingsFragment"
|
||||
tools:layout="@layout/recordings_fragment" />
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue