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()) {