Started call recordings list

This commit is contained in:
Sylvain Berfini 2024-05-15 15:13:18 +02:00
parent 4d561a4635
commit a557875ce8
21 changed files with 805 additions and 88 deletions

View file

@ -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
}
}
}

View file

@ -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
}
}
}

View file

@ -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

View file

@ -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 {

View file

@ -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))

View file

@ -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()
}
}
}

View file

@ -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
}
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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)
}

View file

@ -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

View file

@ -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>

View file

@ -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"

View file

@ -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}"

View 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>

View file

@ -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>

View 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>

View 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>

View file

@ -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" />