From bcc961c32be9aa37d43ef352318bc734139462e3 Mon Sep 17 00:00:00 2001 From: Helmuts Reinis Date: Thu, 25 Mar 2021 03:53:54 +0200 Subject: [PATCH] Add push to record voice and review, then send or delete --- atox/src/main/AndroidManifest.xml | 3 + atox/src/main/kotlin/ui/chat/ChatFragment.kt | 213 ++++++++++++++++-- atox/src/main/kotlin/ui/chat/ChatViewModel.kt | 34 ++- atox/src/main/res/drawable/bubble.xml | 8 + atox/src/main/res/drawable/ic_bas.xml | 19 ++ atox/src/main/res/layout/fragment_chat.xml | 26 +++ atox/src/main/res/values/strings.xml | 2 + .../kotlin/feature/FileTransferManager.kt | 24 +- 8 files changed, 300 insertions(+), 29 deletions(-) create mode 100644 atox/src/main/res/drawable/bubble.xml create mode 100644 atox/src/main/res/drawable/ic_bas.xml diff --git a/atox/src/main/AndroidManifest.xml b/atox/src/main/AndroidManifest.xml index 83a049765..91d006ff1 100644 --- a/atox/src/main/AndroidManifest.xml +++ b/atox/src/main/AndroidManifest.xml @@ -7,6 +7,8 @@ + + @@ -15,6 +17,7 @@ android:allowBackup="false" android:icon="@mipmap/launcher_icon" android:label="@string/app_name" + android:requestLegacyExternalStorage="true" android:roundIcon="@mipmap/launcher_icon_round" android:supportsRtl="true" android:theme="@style/AppTheme"> diff --git a/atox/src/main/kotlin/ui/chat/ChatFragment.kt b/atox/src/main/kotlin/ui/chat/ChatFragment.kt index bcc866804..d3926d4f6 100644 --- a/atox/src/main/kotlin/ui/chat/ChatFragment.kt +++ b/atox/src/main/kotlin/ui/chat/ChatFragment.kt @@ -1,22 +1,34 @@ package ltd.evilcorp.atox.ui.chat +import android.Manifest.permission import android.app.Activity import android.app.AlertDialog import android.content.ActivityNotFoundException import android.content.ClipData import android.content.ClipboardManager import android.content.Intent +import android.content.pm.PackageManager +import android.media.MediaRecorder import android.net.Uri import android.os.Bundle +import android.os.Environment +import android.os.Handler +import android.os.Looper +import android.renderscript.ScriptGroup +import android.util.Log import android.view.ContextMenu import android.view.MenuItem import android.view.MotionEvent import android.view.View import android.widget.AdapterView +import android.widget.ImageButton import android.widget.Toast +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat import androidx.core.content.FileProvider import androidx.core.content.getSystemService import androidx.core.content.res.ResourcesCompat +import androidx.core.net.toUri import androidx.core.os.bundleOf import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat @@ -24,10 +36,6 @@ import androidx.core.view.updatePadding import androidx.core.widget.doAfterTextChanged import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController -import java.io.File -import java.net.URLConnection -import java.text.DateFormat -import java.util.Locale import ltd.evilcorp.atox.BuildConfig import ltd.evilcorp.atox.R import ltd.evilcorp.atox.databinding.FragmentChatBinding @@ -37,16 +45,19 @@ import ltd.evilcorp.atox.ui.BaseFragment import ltd.evilcorp.atox.ui.colorByStatus import ltd.evilcorp.atox.ui.setAvatarFromContact import ltd.evilcorp.atox.vmFactory -import ltd.evilcorp.core.vo.ConnectionStatus -import ltd.evilcorp.core.vo.FileTransfer -import ltd.evilcorp.core.vo.Message -import ltd.evilcorp.core.vo.MessageType -import ltd.evilcorp.core.vo.isComplete +import ltd.evilcorp.core.vo.* import ltd.evilcorp.domain.tox.PublicKey +import java.io.File +import java.io.IOException +import java.net.URLConnection +import java.text.DateFormat +import java.util.* +import java.util.concurrent.TimeUnit const val CONTACT_PUBLIC_KEY = "publicKey" private const val REQUEST_CODE_FT_EXPORT = 1234 private const val REQUEST_CODE_ATTACH = 5678 +private const val REQUEST_RECORD_PERMISSION = 204 private const val MAX_CONFIRM_DELETE_STRING_LENGTH = 20 class ChatFragment : BaseFragment(FragmentChatBinding::inflate) { @@ -56,6 +67,9 @@ class ChatFragment : BaseFragment(FragmentChatBinding::infl private var contactName = "" private var selectedFt: Int = Int.MIN_VALUE private var fts: List = listOf() + private lateinit var mRecorder: MediaRecorder + private var mRecordedFilePath = "" + private var mRecordedFileName = "" override fun onViewCreated(view: View, savedInstanceState: Bundle?): Unit = binding.run { contactPubKey = requireStringArg(CONTACT_PUBLIC_KEY) @@ -171,6 +185,44 @@ class ChatFragment : BaseFragment(FragmentChatBinding::infl registerForContextMenu(send) send.setOnClickListener { send(MessageType.Normal) } + mic.setOnTouchListener { view, motionEvent -> + if (motionEvent.action == MotionEvent.ACTION_DOWN) { + if (checkRecordPermission()) { + startRecording() + startTimer() + } else { + requestRecordPermission() + } + } else if (motionEvent.action == MotionEvent.ACTION_UP) { + + if (checkRecordPermission()) { + stopRecording() + stopTimer() + + try { + if (File(mRecordedFilePath).length() > 0) + AlertDialog.Builder(requireContext()) + .setTitle(R.string.confirm) + .setPositiveButton(R.string.send) { _, _ -> + viewModel.createFt(Uri.fromFile(File(mRecordedFilePath)), File(mRecordedFilePath)) + + } + .setNegativeButton(R.string.delete, { _, _ -> + if (mRecordedFilePath.isNotEmpty()) + viewModel.deleteFile(mRecordedFilePath) + }).show() + else + viewModel.deleteFile(mRecordedFilePath) + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + return@setOnTouchListener true + + } + attach.setOnClickListener { Intent(Intent.ACTION_OPEN_DOCUMENT).apply { addCategory(Intent.CATEGORY_OPENABLE) @@ -192,6 +244,17 @@ class ChatFragment : BaseFragment(FragmentChatBinding::infl override fun onPause() { viewModel.setDraft(binding.outgoingMessage.text.toString()) viewModel.setActiveChat(PublicKey("")) + + + if (binding.timer.visibility == View.VISIBLE) { + stopRecording() + stopTimer() + if (mRecordedFilePath.isNotEmpty()) + viewModel.deleteFile(mRecordedFilePath) + } + + + super.onPause() } @@ -201,6 +264,22 @@ class ChatFragment : BaseFragment(FragmentChatBinding::infl super.onResume() } + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + when (requestCode) { + REQUEST_RECORD_PERMISSION -> { + if (grantResults.size > 0) { + val storagePermission = grantResults[0] == + PackageManager.PERMISSION_GRANTED; + val recordPermission = grantResults[1] == + PackageManager.PERMISSION_GRANTED; + + } + } + + } + } + + override fun onCreateContextMenu( menu: ContextMenu, v: View, @@ -313,14 +392,122 @@ class ChatFragment : BaseFragment(FragmentChatBinding::infl private fun updateActions() = binding.run { send.visibility = if (outgoingMessage.text.isEmpty()) View.GONE else View.VISIBLE - attach.visibility = if (send.visibility == View.VISIBLE) View.GONE else View.VISIBLE - attach.isEnabled = viewModel.contactOnline - attach.setColorFilter( + adjustAttachmentButtons(attach) + adjustAttachmentButtons(mic) + } + + private fun adjustAttachmentButtons(view: ImageButton) = binding.run { + view.visibility = if (send.visibility == View.VISIBLE) View.GONE else View.VISIBLE + view.isEnabled = viewModel.contactOnline + view.setColorFilter( ResourcesCompat.getColor( resources, - if (attach.isEnabled) R.color.colorPrimary else android.R.color.darker_gray, + if (view.isEnabled) R.color.colorPrimary else android.R.color.darker_gray, null ) ) } + + + private fun startRecording() { + try { + mRecorder = MediaRecorder() + mRecorder.setAudioSource(MediaRecorder.AudioSource.MIC) + mRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + mRecorder.setAudioEncoder(MediaRecorder.OutputFormat.AMR_NB) + val rootPath = Environment.getExternalStorageDirectory().absolutePath + val file = File("$rootPath/ATox") + if (!file.exists()) { + file.mkdirs() + } + val fileName = "REC_" + UUID.randomUUID().toString().substring(30, 35) + ".m4a" + + mRecordedFileName = fileName + mRecordedFilePath = "$rootPath/ATox/$fileName" + mRecorder.setOutputFile(mRecordedFilePath) + + mRecorder.prepare() + mRecorder.start() + } catch (e: java.lang.IllegalStateException) { + e.printStackTrace() + } catch (e: IOException) { + e.printStackTrace() + } + } + + + private fun stopRecording() { + try { + try { + mRecorder.stop() + } catch (e: IllegalStateException) { + e.printStackTrace() + } + mRecorder.reset() // set state to idle + mRecorder.release() // release resources back to the system + + } catch (stopexception: RuntimeException) { + Log.d("recview", "rec error") + if (mRecordedFilePath.isNotEmpty()) + viewModel.deleteFile(mRecordedFilePath) + } + } + + + private fun requestRecordPermission() { + ActivityCompat.requestPermissions( + requireActivity(), + arrayOf(permission.WRITE_EXTERNAL_STORAGE, permission.RECORD_AUDIO), + REQUEST_RECORD_PERMISSION + ) + } + + fun checkRecordPermission(): Boolean { + val result = ContextCompat.checkSelfPermission( + requireContext(), + permission.WRITE_EXTERNAL_STORAGE + ) + val result1 = ContextCompat.checkSelfPermission( + requireContext(), + permission.RECORD_AUDIO + ) + return result == PackageManager.PERMISSION_GRANTED && + result1 == PackageManager.PERMISSION_GRANTED + } + + + private val mHandler: Handler = Handler(Looper.getMainLooper()) + + private var mStartTime = 0L + + private val mRunnable: Runnable by lazy { + Runnable { + viewModel.getTimerTime(mStartTime).let { + if (it.isNotEmpty()) + binding.timer.text = it + } + mHandler.postDelayed(mRunnable, TimeUnit.SECONDS.toMillis(1)) + } + } + + fun startTimer() = binding.run { + timer.apply { + visibility = View.VISIBLE + text = context.getString(R.string.default_timer) + + mStartTime = System.currentTimeMillis() + + mHandler.postDelayed(mRunnable, TimeUnit.SECONDS.toMillis(1)) + } + } + + + fun stopTimer() = binding.run { + timer.apply { + visibility = View.GONE + text = context.getString(R.string.default_timer) + } + mHandler.removeCallbacks(mRunnable) + } + } diff --git a/atox/src/main/kotlin/ui/chat/ChatViewModel.kt b/atox/src/main/kotlin/ui/chat/ChatViewModel.kt index ce2edfe25..a7596e450 100644 --- a/atox/src/main/kotlin/ui/chat/ChatViewModel.kt +++ b/atox/src/main/kotlin/ui/chat/ChatViewModel.kt @@ -8,16 +8,9 @@ import android.widget.Toast import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.asLiveData -import java.io.File -import java.io.FileInputStream -import javax.inject.Inject -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.* import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.take -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import ltd.evilcorp.atox.R import ltd.evilcorp.atox.ui.NotificationHelper import ltd.evilcorp.core.vo.Contact @@ -28,6 +21,10 @@ import ltd.evilcorp.domain.feature.ChatManager import ltd.evilcorp.domain.feature.ContactManager import ltd.evilcorp.domain.feature.FileTransferManager import ltd.evilcorp.domain.tox.PublicKey +import java.io.File +import java.io.FileInputStream +import java.text.SimpleDateFormat +import javax.inject.Inject private const val TAG = "ChatViewModel" @@ -55,6 +52,10 @@ class ChatViewModel @Inject constructor( fileTransferManager.deleteAll(publicKey) } + fun deleteFile(path: String) { + fileTransferManager.deleteLocalFile(path) + } + fun setActiveChat(pubKey: PublicKey) { if (pubKey.string().isEmpty()) { Log.i(TAG, "Clearing active chat") @@ -85,7 +86,12 @@ class ChatViewModel @Inject constructor( } fun createFt(file: Uri) = launch { - fileTransferManager.create(publicKey, file) + fileTransferManager.create(publicKey, file, null) + } + + + fun createFt(file: Uri, fileO: File) = launch { + fileTransferManager.create(publicKey, file, fileO) } fun delete(msg: Message) = launch { @@ -117,6 +123,16 @@ class ChatViewModel @Inject constructor( } } + + fun getTimerTime(startTime: Long): String { + val diff = System.currentTimeMillis() - startTime + var seconds = (diff / 1000).toInt() + val minutes = seconds / 60 + seconds = seconds % 60 + + return String.format("%02d:%02d", minutes, seconds) + } + fun setDraft(draft: String) = contactManager.setDraft(publicKey, draft) fun clearDraft() = setDraft("") } diff --git a/atox/src/main/res/drawable/bubble.xml b/atox/src/main/res/drawable/bubble.xml new file mode 100644 index 000000000..2d82ddcc4 --- /dev/null +++ b/atox/src/main/res/drawable/bubble.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/atox/src/main/res/drawable/ic_bas.xml b/atox/src/main/res/drawable/ic_bas.xml new file mode 100644 index 000000000..eda0e3803 --- /dev/null +++ b/atox/src/main/res/drawable/ic_bas.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/atox/src/main/res/layout/fragment_chat.xml b/atox/src/main/res/layout/fragment_chat.xml index 4b4cb1c80..e78b3f15d 100644 --- a/atox/src/main/res/layout/fragment_chat.xml +++ b/atox/src/main/res/layout/fragment_chat.xml @@ -53,6 +53,24 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/toolbar"/> + + + + diff --git a/atox/src/main/res/values/strings.xml b/atox/src/main/res/values/strings.xml index b8896201b..68899dd97 100644 --- a/atox/src/main/res/values/strings.xml +++ b/atox/src/main/res/values/strings.xml @@ -137,4 +137,6 @@ Unable to load bootstrap nodes, please switch to built-in nodes or import nodes again Share to… + + 00:00 diff --git a/domain/src/main/kotlin/feature/FileTransferManager.kt b/domain/src/main/kotlin/feature/FileTransferManager.kt index 71259efcb..ac1395a0f 100644 --- a/domain/src/main/kotlin/feature/FileTransferManager.kt +++ b/domain/src/main/kotlin/feature/FileTransferManager.kt @@ -192,13 +192,18 @@ class FileTransferManager @Inject constructor( fun transfersFor(publicKey: PublicKey) = fileTransferRepository.get(publicKey.string()) - suspend fun create(pk: PublicKey, file: Uri) { - val (name, size) = context.contentResolver.query(file, null, null, null, null, null)?.use { cursor -> - cursor.moveToFirst() - val fileSize = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE)) - val name = cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)) - Pair(name, fileSize) - } ?: return + suspend fun create(pk: PublicKey, file: Uri,fileO:File?) { + val (name, size) = + + if (fileO == null) { + context.contentResolver.query(file, null, null, null, null, null)?.use { cursor -> + cursor.moveToFirst() + val fileSize = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE)) + val name = cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)) + Pair(name, fileSize) + } ?: return + } else + Pair(fileO.name, fileO.length()) val ft = FileTransfer( pk.string(), @@ -269,6 +274,11 @@ class FileTransferManager @Inject constructor( } } + fun deleteLocalFile(filePath: String) { + val file = File(filePath) + if (file.exists()) file.delete() + } + suspend fun delete(id: Int) { fileTransfers.find { it.id == id }?.let { if (it.isStarted() && !it.isComplete()) {