diff --git a/data/src/main/AndroidManifest.xml b/data/src/main/AndroidManifest.xml
index a65cc645..6cf78b81 100644
--- a/data/src/main/AndroidManifest.xml
+++ b/data/src/main/AndroidManifest.xml
@@ -20,4 +20,6 @@
+
+
diff --git a/data/src/main/java/com/moez/QKSMS/extensions/UriExtensions.kt b/data/src/main/java/com/moez/QKSMS/extensions/UriExtensions.kt
index 7ebe330a..2262732c 100644
--- a/data/src/main/java/com/moez/QKSMS/extensions/UriExtensions.kt
+++ b/data/src/main/java/com/moez/QKSMS/extensions/UriExtensions.kt
@@ -18,19 +18,24 @@
*/
package dev.octoshrimpy.quik.extensions
+import android.content.ContentResolver
import android.content.Context
import android.net.Uri
+import androidx.core.net.toFile
fun Uri.resourceExists(context: Context): Boolean {
- // check if resource at uri still exists
+ var retVal: Boolean? = null
try {
- return context.contentResolver.query(
- this, null, null, null, null
- )?.use {
- it.moveToFirst()
- } ?: false
+ when (this.scheme) {
+ ContentResolver.SCHEME_CONTENT -> {
+ retVal = context.contentResolver.query(
+ this, null, null, null, null
+ )?.use { it.moveToFirst() }
+ }
+ ContentResolver.SCHEME_FILE -> retVal = this.toFile().exists()
+ }
+ } catch (e: Exception) { /* nothing */
}
- catch (e: Exception) { /* nothing */ }
- return false
+ return retVal ?: false
}
diff --git a/data/src/main/java/com/moez/QKSMS/manager/MediaRecorderManager.kt b/data/src/main/java/com/moez/QKSMS/manager/MediaRecorderManager.kt
new file mode 100644
index 00000000..157f325b
--- /dev/null
+++ b/data/src/main/java/com/moez/QKSMS/manager/MediaRecorderManager.kt
@@ -0,0 +1,95 @@
+/*
+ * Copyright (C) 2025
+ *
+ * This file is part of QUIK.
+ *
+ * QUIK 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.
+ *
+ * QUIK 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 QUIK. If not, see .
+ */
+package com.moez.QKSMS.manager
+
+import android.content.Context
+import android.media.MediaRecorder
+import android.net.Uri
+import androidx.core.net.toUri
+import java.io.File
+import java.util.UUID
+
+object MediaRecorderManager : MediaRecorder() {
+
+ enum class RecordingState {
+ Initial,
+ DataSourceConfigured,
+ Prepared,
+ Recording,
+ Error
+ }
+
+ const val AUDIO_FILE_PREFIX = "recorded-"
+ const val AUDIO_FILE_SUFFIX = ".3ga"
+
+ var recordingState: RecordingState = RecordingState.Initial
+ private set
+
+ var uri: Uri = Uri.EMPTY
+ private set
+
+ fun stopRecording(): Uri {
+ return try {
+ if (recordingState == RecordingState.Recording)
+ stop()
+
+ reset()
+ recordingState = RecordingState.Initial
+
+ uri
+ }
+ catch (e: Exception) {
+ Uri.EMPTY
+ }
+ }
+
+ fun startRecording(context: Context): Uri {
+ return try {
+ val file = File(
+ context.cacheDir,
+ AUDIO_FILE_PREFIX + UUID.randomUUID() + AUDIO_FILE_SUFFIX
+ )
+
+ // ensure stopped before using again
+ stopRecording()
+
+ // configure
+ setAudioSource(AudioSource.MIC)
+ setOutputFormat(OutputFormat.THREE_GPP)
+ setAudioEncoder(AudioEncoder.AMR_WB)
+ recordingState = RecordingState.DataSourceConfigured
+
+ setOutputFile(file.path)
+
+ prepare()
+ recordingState = RecordingState.Prepared
+
+ start()
+ recordingState = RecordingState.Recording
+
+ uri = file.toUri()
+
+ uri
+ }
+ catch (e: Exception) {
+ Uri.EMPTY
+ }
+ }
+
+}
diff --git a/data/src/main/java/com/moez/QKSMS/manager/PermissionManagerImpl.kt b/data/src/main/java/com/moez/QKSMS/manager/PermissionManagerImpl.kt
index 21955eb0..bc5a6f1c 100644
--- a/data/src/main/java/com/moez/QKSMS/manager/PermissionManagerImpl.kt
+++ b/data/src/main/java/com/moez/QKSMS/manager/PermissionManagerImpl.kt
@@ -72,6 +72,10 @@ class PermissionManagerImpl @Inject constructor(private val context: Context) :
return hasPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)
}
+ override fun hasRecordAudio(): Boolean {
+ return hasPermission(Manifest.permission.RECORD_AUDIO)
+ }
+
override fun hasExactAlarms(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
return true
diff --git a/data/src/main/java/com/moez/QKSMS/repository/MessageRepositoryImpl.kt b/data/src/main/java/com/moez/QKSMS/repository/MessageRepositoryImpl.kt
index db4cc4f4..a93be920 100644
--- a/data/src/main/java/com/moez/QKSMS/repository/MessageRepositoryImpl.kt
+++ b/data/src/main/java/com/moez/QKSMS/repository/MessageRepositoryImpl.kt
@@ -47,6 +47,7 @@ import dev.octoshrimpy.quik.compat.TelephonyCompat
import dev.octoshrimpy.quik.extensions.anyOf
import dev.octoshrimpy.quik.extensions.isImage
import dev.octoshrimpy.quik.extensions.isVideo
+import dev.octoshrimpy.quik.extensions.resourceExists
import dev.octoshrimpy.quik.manager.ActiveConversationManager
import dev.octoshrimpy.quik.manager.KeyManager
import dev.octoshrimpy.quik.model.Attachment
@@ -368,28 +369,31 @@ class MessageRepositoryImpl @Inject constructor(
parts += MMSPart("text", ContentType.TEXT_PLAIN, bytes)
}
- // Attach those that can't be compressed (ie. everything but not images)
+ // Attach those that can't be compressed (ie. everything but images)
parts += attachments
- // filter out images
+ // filter in non-images only
.filter { !it.isImage(context) }
- // filter out items that have no data available (ie user may have deleted
- // the file from storage)
- .filter { it.getResourceBytes(context).isNotEmpty() }
+ // filter in only items that exist (user may have deleted the file)
+ .filter { it.uri.resourceExists(context) }
.map {
remainingBytes -= it.getResourceBytes(context).size
- MMSPart(
+ val mmsPart = MMSPart(
it.getName(context),
it.getType(context),
it.getResourceBytes(context)
)
+
+ // release the attachment hold on the image bytes so the GC can reclaim
+ it.releaseResourceBytes()
+
+ mmsPart
}
val imageBytesByAttachment = attachments
- // filter out non-images
+ // filter in images only
.filter { it.isImage(context) }
- // filter out items that have no data available (ie user may have deleted
- // the file from storage)
- .filter { it.getResourceBytes(context).isNotEmpty() }
+ // filter in only items that exist (user may have deleted the file)
+ .filter { it.uri.resourceExists(context) }
.associateWith {
when (it.getType(context) == "image/gif") {
true -> ImageUtils.getScaledGif(context, it.uri, maxWidth, maxHeight)
@@ -434,6 +438,9 @@ class MessageRepositoryImpl @Inject constructor(
}
Timber.d("Compression attempt $attempts: ${scaledBytes.size / 1024}/${maxBytes.toInt() / 1024}Kb ($width*$height -> $newWidth*$newHeight)")
+
+ // release the attachment hold on the image bytes so the GC can reclaim
+ attachment.releaseResourceBytes()
}
Timber.v("Compressed ${originalBytes.size / 1024}Kb to ${scaledBytes.size / 1024}Kb with a target size of ${maxBytes.toInt() / 1024}Kb in $attempts attempts")
diff --git a/domain/src/main/java/com/moez/QKSMS/interactor/SendMessage.kt b/domain/src/main/java/com/moez/QKSMS/interactor/SendMessage.kt
index b6443e75..23fc11d8 100644
--- a/domain/src/main/java/com/moez/QKSMS/interactor/SendMessage.kt
+++ b/domain/src/main/java/com/moez/QKSMS/interactor/SendMessage.kt
@@ -53,6 +53,9 @@ class SendMessage @Inject constructor(
}
messageRepo.sendMessage(params.subId, threadId, params.addresses, params.body, params.attachments,
params.delay)
+
+ // delete attachment local files, if any, because they're saved to mms db by now
+ params.attachments.forEach { it.removeCacheFile() }
}
.mapNotNull {
// If the threadId wasn't provided, then it's probably because it doesn't exist in Realm.
diff --git a/domain/src/main/java/com/moez/QKSMS/manager/PermissionManager.kt b/domain/src/main/java/com/moez/QKSMS/manager/PermissionManager.kt
index c1a30c63..c1c0432a 100644
--- a/domain/src/main/java/com/moez/QKSMS/manager/PermissionManager.kt
+++ b/domain/src/main/java/com/moez/QKSMS/manager/PermissionManager.kt
@@ -36,6 +36,8 @@ interface PermissionManager {
fun hasStorage(): Boolean
+ fun hasRecordAudio(): Boolean
+
fun hasExactAlarms(): Boolean
}
diff --git a/domain/src/main/java/com/moez/QKSMS/model/Attachment.kt b/domain/src/main/java/com/moez/QKSMS/model/Attachment.kt
index 8957a969..257a1ba4 100644
--- a/domain/src/main/java/com/moez/QKSMS/model/Attachment.kt
+++ b/domain/src/main/java/com/moez/QKSMS/model/Attachment.kt
@@ -19,11 +19,14 @@
package dev.octoshrimpy.quik.model
import android.annotation.SuppressLint
+import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.os.Build
import android.provider.ContactsContract
import android.provider.OpenableColumns
+import android.webkit.MimeTypeMap
+import androidx.core.net.toFile
import androidx.core.view.inputmethod.InputContentInfoCompat
@@ -41,7 +44,8 @@ class Attachment (
// if constructed with a uri to a contact, convert uri to associated vcard uri
if (context.contentResolver.getType(uri)?.lowercase() ==
- ContactsContract.Contacts.CONTENT_ITEM_TYPE) {
+ ContactsContract.Contacts.CONTENT_ITEM_TYPE
+ ) {
uri = try {
Uri.withAppendedPath(
ContactsContract.Contacts.CONTENT_VCARD_URI,
@@ -59,21 +63,34 @@ class Attachment (
}
fun getType(context: Context): String {
- return context.contentResolver.getType(uri) ?: "application/octect-stream"
+ var retVal: String? = null
+ when (uri.scheme) {
+ ContentResolver.SCHEME_CONTENT -> retVal = context.contentResolver.getType(uri)
+ ContentResolver.SCHEME_FILE -> retVal =
+ MimeTypeMap.getSingleton().getMimeTypeFromExtension(uri.toFile().extension)
+ }
+ return retVal ?: "application/octect-stream"
}
fun getName(context: Context): String {
+ var retVal: String? = null
try {
- context.contentResolver.query(
- uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null
- )?.use {
- it.moveToFirst()
- return it.getString(it.getColumnIndex(OpenableColumns.DISPLAY_NAME)) ?: "unknown"
- }
- }
- catch (e: Exception) { /* nothing */ }
+ when (uri.scheme) {
+ ContentResolver.SCHEME_CONTENT -> {
+ context.contentResolver.query(
+ uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null
+ )?.use {
+ it.moveToFirst()
+ retVal = it.getString(it.getColumnIndex(OpenableColumns.DISPLAY_NAME))
+ }
+ }
- return "unknown"
+ ContentResolver.SCHEME_FILE -> retVal = uri.toFile().name
+ }
+ } catch (e: Exception) { /* nothing */
+ }
+
+ return retVal ?: "unknown"
}
fun isVCard(context: Context): Boolean {
@@ -94,17 +111,24 @@ class Attachment (
}
fun getSize(context: Context): Long {
+ var retVal: Long? = null
try {
- context.contentResolver.query(
- uri, arrayOf(OpenableColumns.SIZE), null, null, null
- )?.use {
- it.moveToFirst()
- return it.getLong(it.getColumnIndex(OpenableColumns.SIZE))
- }
- }
- catch (e: Exception) { /* nothing */ }
+ when (uri.scheme) {
+ ContentResolver.SCHEME_CONTENT -> {
+ context.contentResolver.query(
+ uri, arrayOf(OpenableColumns.SIZE), null, null, null
+ )?.use {
+ it.moveToFirst()
+ retVal = it.getLong(it.getColumnIndex(OpenableColumns.SIZE))
+ }
+ }
- return -1
+ ContentResolver.SCHEME_FILE -> retVal = uri.toFile().length()
+ }
+ } catch (e: Exception) { /* nothing */
+ }
+
+ return retVal ?: -1
}
fun getResourceBytes(context: Context): ByteArray {
@@ -121,6 +145,18 @@ class Attachment (
return resourceBytes ?: ByteArray(0)
}
+ fun releaseResourceBytes() {
+ resourceBytes = null
+ }
+
+ fun removeCacheFile(): Boolean {
+ // all file:// scheme files are local to the app cache dir, so can be deleted
+ if (uri.scheme == ContentResolver.SCHEME_FILE) {
+ return try { uri.toFile().delete() }
+ catch (e: Exception) { false }
+ }
+
+ return false
+ }
}
-class Attachments(attachments: List) : List by attachments
diff --git a/presentation/src/main/java/com/moez/QKSMS/common/widget/MicInputCloudView.kt b/presentation/src/main/java/com/moez/QKSMS/common/widget/MicInputCloudView.kt
new file mode 100644
index 00000000..e5eab160
--- /dev/null
+++ b/presentation/src/main/java/com/moez/QKSMS/common/widget/MicInputCloudView.kt
@@ -0,0 +1,404 @@
+/*
+ * Copyright (C) - see below
+ *
+ * This file is part of QUIK.
+ *
+ * QUIK 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.
+ *
+ * QUIK 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 QUIK. If not, see .
+ */
+/*
+ * Original from: Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2023 Julius Linus
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package dev.octoshrimpy.quik.common.widget
+
+import android.animation.ValueAnimator
+import android.content.Context
+import android.content.res.Resources
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.graphics.Paint.ANTI_ALIAS_FLAG
+import android.graphics.Path
+import android.graphics.Rect
+import android.graphics.drawable.Drawable
+import android.util.AttributeSet
+import android.view.View
+import android.view.animation.LinearInterpolator
+import androidx.annotation.ColorInt
+import dev.octoshrimpy.quik.R
+import kotlin.math.roundToInt
+
+class MicInputCloudView(context: Context, attrs: AttributeSet) : View(context, attrs) {
+ /**
+ * State Descriptions:
+ * - PAUSED_STATE: Animation speed is set to zero.
+ * - PLAY_STATE: Animation speed is set to default, but can be overridden.
+ */
+ enum class ViewState {
+ /**
+ * Animation speed is set to zero.
+ */
+ PAUSED_STATE,
+
+ /**
+ * Animation speed is set to default, but can be overridden.
+ */
+ PLAY_STATE
+ }
+
+ @ColorInt
+ private var primaryColor: Int = Color.WHITE
+
+ private var pauseIcon: Drawable? = null
+
+ private var playIcon: Drawable? = null
+
+ init {
+ context.theme.obtainStyledAttributes(
+ attrs,
+ R.styleable.MicInputCloudView,
+ 0,
+ 0
+ ).apply {
+
+ try {
+ pauseIcon = getDrawable(R.styleable.MicInputCloudView_pauseIcon)
+ playIcon = getDrawable(R.styleable.MicInputCloudView_playIcon)
+ } finally {
+ recycle()
+ }
+ }
+ }
+
+ private var state: ViewState = ViewState.PLAY_STATE
+ private var ovalOneAnimator: ValueAnimator? = null
+ private var ovalTwoAnimator: ValueAnimator? = null
+ private var ovalThreeAnimator: ValueAnimator? = null
+ private var r1 = OVAL_ONE_DEFAULT_ROTATION
+ private var r2 = OVAL_TWO_DEFAULT_ROTATION
+ private var r3 = OVAL_THREE_DEFAULT_ROTATION
+ private var o1h = OVAL_ONE_DEFAULT_HEIGHT
+ private var o1w = OVAL_ONE_DEFAULT_WIDTH
+ private var o2h = OVAL_TWO_DEFAULT_HEIGHT
+ private var o2w = OVAL_TWO_DEFAULT_WIDTH
+ private var o3h = OVAL_THREE_DEFAULT_HEIGHT
+ private var o3w = OVAL_THREE_DEFAULT_WIDTH
+ private var rotationSpeedMultiplier: Float = DEFAULT_ROTATION_SPEED_MULTIPLIER
+ private var radius: Float = DEFAULT_RADIUS
+ private var centerX: Float = 0f
+ private var centerY: Float = 0f
+
+ private val bottomCirclePaint = Paint(ANTI_ALIAS_FLAG).apply {
+ color = primaryColor
+ style = Paint.Style.FILL
+ alpha = DEFAULT_OPACITY
+ }
+
+ private val topCircleBounds = Rect(0, 0, 0, 0)
+ private val iconBounds = topCircleBounds
+
+ override fun onVisibilityChanged(changedView: View, visibility: Int) {
+ super.onVisibilityChanged(changedView, visibility)
+ if (visibility == VISIBLE) {
+ createAnimators()
+ } else {
+ state = ViewState.PLAY_STATE
+ destroyAnimators()
+ }
+ }
+
+ private fun createAnimators() {
+ ovalOneAnimator = ValueAnimator.ofInt(
+ o1h,
+ OVAL_ONE_DEFAULT_HEIGHT + ANIMATION_CAP,
+ o1h
+ ).apply {
+ duration = OVAL_ONE_ANIMATION_LENGTH
+ interpolator = LinearInterpolator()
+ repeatCount = ValueAnimator.INFINITE
+ addUpdateListener { valueAnimator ->
+ o1h = valueAnimator.animatedValue as Int
+ }
+ }
+
+ ovalTwoAnimator = ValueAnimator.ofInt(
+ o2h,
+ OVAL_TWO_DEFAULT_HEIGHT + ANIMATION_CAP,
+ o2h
+ ).apply {
+ duration = OVAL_TWO_ANIMATION_LENGTH
+ interpolator = LinearInterpolator()
+ repeatCount = ValueAnimator.INFINITE
+ addUpdateListener { valueAnimator ->
+ o2h = valueAnimator.animatedValue as Int
+ }
+ }
+
+ ovalThreeAnimator = ValueAnimator.ofInt(
+ o3h,
+ OVAL_THREE_DEFAULT_HEIGHT + ANIMATION_CAP,
+ o3h
+ ).apply {
+ duration = OVAL_THREE_ANIMATION_LENGTH
+ interpolator = LinearInterpolator()
+ repeatCount = ValueAnimator.INFINITE
+ addUpdateListener { valueAnimator ->
+ o3h = valueAnimator.animatedValue as Int
+ invalidate() // needed to animate the other listeners as well
+ }
+ }
+ }
+
+ private fun destroyAnimators() {
+ ovalOneAnimator?.cancel()
+ ovalOneAnimator?.removeAllUpdateListeners()
+ ovalTwoAnimator?.cancel()
+ ovalTwoAnimator?.removeAllUpdateListeners()
+ ovalThreeAnimator?.cancel()
+ ovalThreeAnimator?.removeAllUpdateListeners()
+ }
+
+ private val circlePath: Path = Path()
+ private val ovalOnePath: Path = Path()
+ private val ovalTwoPath: Path = Path()
+ private val ovalThreePath: Path = Path()
+
+ override fun onDraw(canvas: Canvas) {
+ super.onDraw(canvas)
+ circlePath.apply {
+ addCircle(centerX, centerY, DEFAULT_RADIUS, Path.Direction.CCW)
+ }
+ ovalOnePath.apply {
+ addOval(
+ centerX - (radius + o1w),
+ centerY - o1h,
+ centerX + (radius + o1w),
+ centerY + o1h,
+ Path.Direction.CCW
+ )
+ op(this, circlePath, Path.Op.DIFFERENCE)
+ }
+ ovalTwoPath.apply {
+ addOval(
+ centerX - (radius + o2w),
+ centerY - o2h,
+ centerX + (radius + o2w),
+ centerY + o2h,
+ Path.Direction.CCW
+ )
+ op(this, circlePath, Path.Op.DIFFERENCE)
+ }
+ ovalThreePath.apply {
+ addOval(
+ centerX - (radius + o3w),
+ centerY - o3h,
+ centerX + (radius + o3w),
+ centerY + o3h,
+ Path.Direction.CCW
+ )
+ op(this, circlePath, Path.Op.DIFFERENCE)
+ }
+ drawMicInputCloud(canvas)
+ if (state == ViewState.PLAY_STATE) {
+ r1 += OVAL_ONE_ANIMATION_SPEED * rotationSpeedMultiplier
+ r2 -= OVAL_TWO_ANIMATION_SPEED * rotationSpeedMultiplier
+ r3 += OVAL_THREE_ANIMATION_SPEED * rotationSpeedMultiplier
+ invalidate()
+ }
+ }
+
+ private fun drawMicInputCloud(canvas: Canvas?) {
+ canvas?.apply {
+ save()
+ rotate(r1, centerX, centerY)
+ drawPath(ovalOnePath, bottomCirclePaint)
+ restore()
+ save()
+ rotate(r2, centerX, centerY)
+ drawPath(ovalTwoPath, bottomCirclePaint)
+ restore()
+ save()
+ rotate(r3, centerX, centerY)
+ drawPath(ovalThreePath, bottomCirclePaint)
+ restore()
+ circlePath.reset()
+ ovalOnePath.reset()
+ ovalTwoPath.reset()
+ ovalThreePath.reset()
+ if (state == ViewState.PLAY_STATE) {
+ pauseIcon?.apply {
+ bounds = topCircleBounds
+ setTint(primaryColor)
+ draw(canvas)
+ }
+ } else {
+ playIcon?.apply {
+ bounds = topCircleBounds
+ setTint(primaryColor)
+ draw(canvas)
+ }
+ }
+ }
+ }
+
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
+ val desiredWidth = DEFAULT_SIZE.dp
+ val desiredHeight = DEFAULT_SIZE.dp
+
+ val widthMode = MeasureSpec.getMode(widthMeasureSpec)
+ val widthSize = MeasureSpec.getSize(widthMeasureSpec)
+ val heightMode = MeasureSpec.getMode(heightMeasureSpec)
+ val heightSize = MeasureSpec.getSize(heightMeasureSpec)
+
+ val width: Int = when (widthMode) {
+ MeasureSpec.EXACTLY -> {
+ widthSize
+ }
+
+ MeasureSpec.AT_MOST -> {
+ desiredWidth.coerceAtMost(widthSize)
+ }
+
+ else -> {
+ desiredWidth
+ }
+ }
+
+ val height: Int = when (heightMode) {
+ MeasureSpec.EXACTLY -> {
+ heightSize
+ }
+
+ MeasureSpec.AT_MOST -> {
+ desiredHeight.coerceAtMost(heightSize)
+ }
+
+ else -> {
+ desiredHeight
+ }
+ }
+
+ centerX = (width / 2).toFloat()
+ centerY = (height / 2).toFloat()
+ topCircleBounds.apply {
+ left = (centerX - DEFAULT_RADIUS).toInt()
+ top = (centerY - DEFAULT_RADIUS).toInt()
+ right = (centerX + DEFAULT_RADIUS).toInt()
+ bottom = (centerY + DEFAULT_RADIUS).toInt()
+ }
+
+ /**
+ * Drawables are drawn the same way as the canvas is drawn, as both originate from the top-left corner.
+ * Because of this, the icon's width = (right - left) and height = (bottom - top).
+ */
+ iconBounds.apply {
+ left = (centerX - DEFAULT_RADIUS + ICON_SIZE.dp).toInt()
+ top = (centerY - DEFAULT_RADIUS + ICON_SIZE.dp).toInt()
+ right = (centerX + DEFAULT_RADIUS - ICON_SIZE.dp).toInt()
+ bottom = (centerY + DEFAULT_RADIUS - ICON_SIZE.dp).toInt()
+ }
+
+ setMeasuredDimension(width, height)
+ }
+
+ override fun performClick(): Boolean {
+ state = if (state == ViewState.PAUSED_STATE) {
+ ovalOneAnimator?.resume()
+ ovalTwoAnimator?.resume()
+ ovalThreeAnimator?.resume()
+ ViewState.PLAY_STATE
+ } else {
+ ovalOneAnimator?.pause()
+ ovalTwoAnimator?.pause()
+ ovalThreeAnimator?.pause()
+ ViewState.PAUSED_STATE
+ }
+ invalidate()
+ return super.performClick()
+ }
+
+ /**
+ * Sets the color of the cloud to the parameter, opacity is still set to 50%.
+ */
+ fun setColor(primary: Int) {
+ primaryColor = primary
+ bottomCirclePaint.apply {
+ color = primary
+ style = Paint.Style.FILL
+ alpha = DEFAULT_OPACITY
+ }
+ invalidate()
+ }
+
+ /**
+ * Sets state of the component to the parameter, must be of type MicInputCloud.ViewState.
+ */
+ fun setState(s: ViewState) {
+ state = s
+ invalidate()
+ }
+
+ fun getState() : ViewState {
+ return state
+ }
+
+ /**
+ * Sets the rotation speed and radius to the parameters, defaults are left unchanged.
+ */
+ fun setRotationSpeed(speed: Float, r: Float) {
+ rotationSpeedMultiplier = speed
+ radius = r
+ invalidate()
+ }
+
+ /**
+ * Starts the growing and shrinking animation
+ */
+ fun startAnimators() {
+ ovalOneAnimator?.start()
+ ovalTwoAnimator?.start()
+ ovalThreeAnimator?.start()
+ }
+
+ companion object {
+ val TAG: String? = MicInputCloudView::class.simpleName
+ const val DEFAULT_RADIUS: Float = 70f
+ const val EXTENDED_RADIUS: Float = 75f
+ const val MAXIMUM_RADIUS: Float = 80f
+ const val ICON_SIZE: Int = 9 // Converted to dp this equals about 24dp
+ private const val DEFAULT_SIZE: Int = 110
+ private const val DEFAULT_OPACITY: Int = 108
+ private const val DEFAULT_ROTATION_SPEED_MULTIPLIER: Float = 0.5f
+ private const val OVAL_ONE_DEFAULT_ROTATION: Float = 105f
+ private const val OVAL_ONE_DEFAULT_HEIGHT: Int = 85
+ private const val OVAL_ONE_DEFAULT_WIDTH: Int = 30
+ private const val OVAL_ONE_ANIMATION_LENGTH: Long = 2000
+ private const val OVAL_ONE_ANIMATION_SPEED: Float = 2.3f
+ private const val OVAL_TWO_DEFAULT_ROTATION: Float = 138f
+ private const val OVAL_TWO_DEFAULT_HEIGHT: Int = 70
+ private const val OVAL_TWO_DEFAULT_WIDTH: Int = 25
+ private const val OVAL_TWO_ANIMATION_LENGTH: Long = 1000
+ private const val OVAL_TWO_ANIMATION_SPEED: Float = 1.75f
+ private const val OVAL_THREE_DEFAULT_ROTATION: Float = 63f
+ private const val OVAL_THREE_DEFAULT_HEIGHT: Int = 80
+ private const val OVAL_THREE_DEFAULT_WIDTH: Int = 40
+ private const val OVAL_THREE_ANIMATION_LENGTH: Long = 1500
+ private const val OVAL_THREE_ANIMATION_SPEED: Float = 1f
+ private const val ANIMATION_CAP: Int = 15
+ private val Int.dp: Int
+ get() = (this * Resources.getSystem().displayMetrics.density).roundToInt()
+ }
+}
diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeActivity.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeActivity.kt
index 5c783439..def29466 100644
--- a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeActivity.kt
+++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeActivity.kt
@@ -25,8 +25,10 @@ import android.app.DatePickerDialog
import android.app.TimePickerDialog
import android.content.ContentValues
import android.content.Intent
+import android.content.res.ColorStateList
import android.net.Uri
import android.os.Bundle
+import android.os.SystemClock
import android.provider.ContactsContract
import android.provider.MediaStore
import android.speech.RecognizerIntent
@@ -39,6 +41,9 @@ import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.Toast
+import android.view.View.OnTouchListener
+import android.widget.SeekBar
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.ConstraintSet
@@ -52,6 +57,7 @@ import com.google.android.material.snackbar.Snackbar
import com.jakewharton.rxbinding2.view.clicks
import com.jakewharton.rxbinding2.widget.textChanges
import com.moez.QKSMS.common.QkMediaPlayer
+import com.uber.autodispose.ObservableSubscribeProxy
import com.uber.autodispose.android.lifecycle.scope
import com.uber.autodispose.autoDisposable
import dagger.android.AndroidInjection
@@ -67,6 +73,7 @@ import dev.octoshrimpy.quik.common.util.extensions.setBackgroundTint
import dev.octoshrimpy.quik.common.util.extensions.setTint
import dev.octoshrimpy.quik.common.util.extensions.setVisible
import dev.octoshrimpy.quik.common.util.extensions.showKeyboard
+import dev.octoshrimpy.quik.common.widget.MicInputCloudView
import dev.octoshrimpy.quik.common.widget.QkEditText
import dev.octoshrimpy.quik.extensions.mapNotNull
import dev.octoshrimpy.quik.feature.compose.editing.ChipsAdapter
@@ -74,13 +81,23 @@ import dev.octoshrimpy.quik.feature.contacts.ContactsActivity
import dev.octoshrimpy.quik.model.Attachment
import dev.octoshrimpy.quik.model.Recipient
import io.reactivex.Observable
+import io.reactivex.android.schedulers.AndroidSchedulers
+import io.reactivex.disposables.Disposable
+import io.reactivex.schedulers.Schedulers
import io.reactivex.subjects.PublishSubject
import io.reactivex.subjects.Subject
import kotlinx.android.synthetic.main.compose_activity.attach
import kotlinx.android.synthetic.main.compose_activity.attachAFileIcon
import kotlinx.android.synthetic.main.compose_activity.attachAFileLabel
import kotlinx.android.synthetic.main.compose_activity.attaching
-import kotlinx.android.synthetic.main.compose_activity.attachingBackground
+import kotlinx.android.synthetic.main.compose_activity.shadeBackground
+import kotlinx.android.synthetic.main.compose_activity.audioMsgAbort
+import kotlinx.android.synthetic.main.compose_activity.audioMsgAttach
+import kotlinx.android.synthetic.main.compose_activity.audioMsgBackground
+import kotlinx.android.synthetic.main.compose_activity.audioMsgPlayerBackground
+import kotlinx.android.synthetic.main.compose_activity.audioMsgPlayerPlayPause
+import kotlinx.android.synthetic.main.compose_activity.audioMsgPlayerSeekBar
+import kotlinx.android.synthetic.main.compose_activity.audioMsgRecord
import kotlinx.android.synthetic.main.compose_activity.camera
import kotlinx.android.synthetic.main.compose_activity.cameraLabel
import kotlinx.android.synthetic.main.compose_activity.chips
@@ -96,7 +113,11 @@ import kotlinx.android.synthetic.main.compose_activity.message
import kotlinx.android.synthetic.main.compose_activity.messageList
import kotlinx.android.synthetic.main.compose_activity.messagesEmpty
import kotlinx.android.synthetic.main.compose_activity.noValidRecipients
-import kotlinx.android.synthetic.main.compose_activity.parts
+import kotlinx.android.synthetic.main.compose_activity.messageAttachments
+import kotlinx.android.synthetic.main.compose_activity.attachAnAudioMessageIcon
+import kotlinx.android.synthetic.main.compose_activity.attachAnAudioMessageLabel
+import kotlinx.android.synthetic.main.compose_activity.audioMsgDuration
+import kotlinx.android.synthetic.main.compose_activity.recordAudioMsg
import kotlinx.android.synthetic.main.compose_activity.schedule
import kotlinx.android.synthetic.main.compose_activity.scheduleLabel
import kotlinx.android.synthetic.main.compose_activity.scheduledCancel
@@ -118,12 +139,13 @@ import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
+import java.util.concurrent.TimeUnit
import javax.inject.Inject
class ComposeActivity : QkThemedActivity(), ComposeView {
- @Inject lateinit var attachmentAdapter: AttachmentAdapter
+ @Inject lateinit var composeAttachmentAdapter: ComposeAttachmentAdapter
@Inject lateinit var chipsAdapter: ChipsAdapter
@Inject lateinit var dateFormatter: DateFormatter
@Inject lateinit var messageAdapter: MessagesAdapter
@@ -144,9 +166,9 @@ class ComposeActivity : QkThemedActivity(), ComposeView {
override val cancelSendingIntent: Subject by lazy { messageAdapter.cancelSendingClicks }
override val sendNowIntent: Subject by lazy { messageAdapter.sendNowClicks }
override val resendIntent: Subject by lazy { messageAdapter.resendClicks }
- override val attachmentDeletedIntent: Subject by lazy { attachmentAdapter.attachmentDeleted }
+ override val attachmentDeletedIntent: Subject by lazy { composeAttachmentAdapter.attachmentDeleted }
override val textChangedIntent by lazy { message.textChanges() }
- override val attachIntent by lazy { Observable.merge(attach.clicks(), attachingBackground.clicks()) }
+ override val attachIntent by lazy { Observable.merge(attach.clicks(), shadeBackground.clicks()) }
override val cameraIntent by lazy { Observable.merge(camera.clicks(), cameraLabel.clicks()) }
override val attachImageFileIntent by lazy { Observable.merge(gallery.clicks(), galleryLabel.clicks()) }
override val attachAnyFileIntent by lazy { Observable.merge(attachAFileIcon.clicks(), attachAFileLabel.clicks()) }
@@ -164,11 +186,53 @@ class ComposeActivity : QkThemedActivity(), ComposeView {
override val confirmDeleteIntent: Subject> = PublishSubject.create()
override val messageLinkAskIntent: Subject by lazy { messageAdapter.messageLinkClicks }
override val speechRecogniserIntent by lazy { speechToTextIcon.clicks() }
+ override val shadeIntent by lazy { shadeBackground.clicks() }
+ override val recordAnAudioMessage by lazy {
+ Observable.merge(recordAudioMsg.clicks(),
+ attachAnAudioMessageIcon.clicks(),
+ attachAnAudioMessageLabel.clicks())
+ }
+ override val recordAudioAbort by lazy { audioMsgAbort.clicks() }
+ override val recordAudioAttach by lazy { audioMsgAttach.clicks() }
+ override val recordAudioPlayerPlayPause: Subject = PublishSubject.create()
+ override val recordAudioPlayerConfigUI: Subject = PublishSubject.create()
+ override val recordAudioPlayerVisible: Subject = PublishSubject.create()
+ override val recordAudioChronometer: Subject = PublishSubject.create()
+ override val recordAudioStartStop: Subject = PublishSubject.create()
+
+ private var seekBarUpdater: Disposable? = null
private val viewModel by lazy { ViewModelProviders.of(this, viewModelFactory)[ComposeViewModel::class.java] }
private var cameraDestination: Uri? = null
+ private fun getSeekBarUpdater(): ObservableSubscribeProxy {
+ return Observable.interval(500, TimeUnit.MILLISECONDS)
+ .subscribeOn(Schedulers.single())
+ .observeOn(AndroidSchedulers.mainThread())
+ .autoDisposable(scope())
+ }
+
+ private val speechResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ if (result.resultCode != Activity.RESULT_OK)
+ return@registerForActivityResult
+
+ // check returned results are good
+ val match = result.data?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
+ if ((match === null) || (match.size < 1) || (match[0].isNullOrEmpty()))
+ return@registerForActivityResult
+
+ // get the edit text view
+ val message = findViewById(R.id.message)
+ if (message === null)
+ return@registerForActivityResult
+
+ // populate message box with data returned by STT, set cursor to end, and focus
+ message.append(match[0])
+ message.setSelection(message.text.length)
+ message.requestFocus()
+ }
+
override fun onCreate(savedInstanceState: Bundle?) {
AndroidInjection.inject(this)
super.onCreate(savedInstanceState)
@@ -196,15 +260,24 @@ class ComposeActivity : QkThemedActivity(), ComposeView {
message.supportsInputContent = true
theme
- .doOnNext { loading.setTint(it.theme) }
- .doOnNext { attach.setBackgroundTint(it.theme) }
- .doOnNext { attach.setTint(it.textPrimary) }
- .doOnNext { speechToTextIconBorder.setBackgroundTint(it.theme) }
- .doOnNext { speechToTextIcon.setBackgroundTint(it.textPrimary) }
- .doOnNext { speechToTextIcon.setTint(it.theme) }
- .doOnNext { messageAdapter.theme = it }
- .autoDisposable(scope())
- .subscribe()
+ .doOnNext { loading.setTint(it.theme) }
+ .doOnNext { attach.setBackgroundTint(it.theme) }
+ .doOnNext { attach.setTint(it.textPrimary) }
+ .doOnNext { speechToTextIconBorder.setBackgroundTint(it.theme) }
+ .doOnNext { speechToTextIcon.setBackgroundTint(it.textPrimary) }
+ .doOnNext { speechToTextIcon.setTint(it.theme) }
+ .doOnNext { audioMsgRecord.setColor(it.theme) }
+ .doOnNext { audioMsgPlayerPlayPause.setTint(it.theme) }
+ .doOnNext {
+ audioMsgPlayerSeekBar.apply {
+ thumbTintList = ColorStateList.valueOf(it.theme)
+ progressBackgroundTintList = ColorStateList.valueOf(it.theme)
+ progressTintList = ColorStateList.valueOf(it.theme)
+ }
+ }
+ .doOnNext { messageAdapter.theme = it }
+ .autoDisposable(scope())
+ .subscribe()
// context menu registration for message parts
messagePartContextMenuRegistrar
@@ -236,6 +309,86 @@ class ComposeActivity : QkThemedActivity(), ComposeView {
true
}
+ // start/stop audio message recording
+ audioMsgRecord.setOnClickListener {
+ recordAudioStartStop.onNext(audioMsgRecord.getState())
+ }
+
+ recordAudioChronometer
+ .subscribeOn(AndroidSchedulers.mainThread())
+ .distinctUntilChanged()
+ .autoDisposable(scope())
+ .subscribe {
+ if (it) {
+ audioMsgDuration.base = SystemClock.elapsedRealtime()
+ audioMsgDuration.start()
+ } else {
+ audioMsgDuration.stop()
+ }
+ }
+
+ // audio record playback play/pause button
+ audioMsgPlayerPlayPause.setOnClickListener {
+ recordAudioPlayerPlayPause.onNext(
+ audioMsgPlayerPlayPause.tag as QkMediaPlayer.PlayingState
+ )
+ }
+
+ recordAudioPlayerVisible
+ .subscribeOn(AndroidSchedulers.mainThread())
+ .distinctUntilChanged()
+ .autoDisposable(scope())
+ .subscribe {
+ audioMsgPlayerBackground.isVisible = it
+ recordAudioPlayerConfigUI.onNext(QkMediaPlayer.PlayingState.Stopped)
+ }
+
+ recordAudioPlayerConfigUI
+ .subscribeOn(AndroidSchedulers.mainThread())
+ .distinctUntilChanged()
+ .autoDisposable(scope())
+ .subscribe {
+ when (it) {
+ QkMediaPlayer.PlayingState.Playing -> {
+ audioMsgPlayerPlayPause.tag = QkMediaPlayer.PlayingState.Playing
+ QkMediaPlayer.start()
+ audioMsgPlayerPlayPause.setImageResource(R.drawable.exo_icon_pause)
+ seekBarUpdater = getSeekBarUpdater().subscribe {
+ audioMsgPlayerSeekBar.progress = QkMediaPlayer.currentPosition
+ audioMsgPlayerSeekBar.max = QkMediaPlayer.duration
+ }
+ audioMsgPlayerSeekBar.isEnabled = true
+ }
+ QkMediaPlayer.PlayingState.Paused -> {
+ audioMsgPlayerPlayPause.tag = QkMediaPlayer.PlayingState.Paused
+ QkMediaPlayer.pause()
+ audioMsgPlayerPlayPause.setImageResource(R.drawable.exo_icon_play)
+ seekBarUpdater?.dispose()
+ }
+ else -> {
+ audioMsgPlayerPlayPause.tag = QkMediaPlayer.PlayingState.Stopped
+ QkMediaPlayer.reset()
+ audioMsgPlayerPlayPause.setImageResource(R.drawable.exo_icon_play)
+ seekBarUpdater?.dispose()
+ audioMsgPlayerSeekBar.progress = 0
+ audioMsgPlayerSeekBar.isEnabled = false
+ }
+ }
+ }
+
+ // audio msg player seek bar handler
+ audioMsgPlayerSeekBar.setOnSeekBarChangeListener(
+ object : SeekBar.OnSeekBarChangeListener {
+ override fun onProgressChanged(p0: SeekBar?, progress: Int, fromUser: Boolean) {
+ // if seek was initiated by the user and this part is currently playing
+ if (fromUser)
+ QkMediaPlayer.seekTo(progress)
+ }
+ override fun onStartTrackingTouch(p0: SeekBar?) {}
+ override fun onStopTrackingTouch(p0: SeekBar?) {}
+ }
+ )
+
window.callback = ComposeWindowCallback(window.callback, this)
}
@@ -264,6 +417,8 @@ class ComposeActivity : QkThemedActivity(), ComposeView {
// stop any playing audio
QkMediaPlayer.reset()
+
+ seekBarUpdater?.dispose()
}
@@ -321,12 +476,19 @@ class ComposeActivity : QkThemedActivity(), ComposeView {
scheduledGroup.isVisible = state.scheduled != 0L
scheduledTime.text = dateFormatter.getScheduledTimestamp(state.scheduled)
- parts.setVisible(state.attachments.isNotEmpty())
- attachmentAdapter.data = state.attachments
+ messageAttachments.setVisible(state.attachments.isNotEmpty())
+ composeAttachmentAdapter.data = state.attachments
attach.animate().rotation(if (state.attaching) 135f else 0f).start()
attaching.isVisible = state.attaching
+ shadeBackground.visibility =
+ if (state.attaching || state.audioMsgRecording) View.VISIBLE
+ else View.GONE
+
+ // show or hide audio message recording panel and shade background
+ audioMsgBackground.isVisible = state.audioMsgRecording
+
counter.text = state.remaining
counter.setVisible(counter.text.isNotBlank())
@@ -334,8 +496,9 @@ class ComposeActivity : QkThemedActivity(), ComposeView {
sim.contentDescription = getString(R.string.compose_sim_cd, state.subscription?.displayName)
simIndex.text = state.subscription?.simSlotIndex?.plus(1)?.toString()
- send.isEnabled = state.canSend
- send.imageAlpha = if (state.canSend) 255 else 128
+ // show either send or audio msg record button
+ send.visibility = if (state.canSend && !state.loading) View.VISIBLE else View.INVISIBLE
+ recordAudioMsg.visibility = if (state.canSend && !state.loading) View.INVISIBLE else View.VISIBLE
// if not in editing mode, and there are no non-me participants that can be sent to,
// hide controls that allow constructing a reply and inform user no valid recipients
@@ -418,6 +581,10 @@ class ComposeActivity : QkThemedActivity(), ComposeView {
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), 0)
}
+ override fun requestRecordAudioPermission() {
+ ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.RECORD_AUDIO), 0)
+ }
+
override fun requestSmsPermission() {
ActivityCompat.requestPermissions(this, arrayOf(
Manifest.permission.READ_SMS,
diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeActivityModule.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeActivityModule.kt
index 9063b86b..fcc5646f 100644
--- a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeActivityModule.kt
+++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeActivityModule.kt
@@ -21,15 +21,12 @@ package dev.octoshrimpy.quik.feature.compose
import android.content.Intent
import android.net.Uri
import androidx.lifecycle.ViewModel
-import com.google.android.mms.ContentType
import dev.octoshrimpy.quik.injection.ViewModelKey
import dev.octoshrimpy.quik.model.Attachment
-import dev.octoshrimpy.quik.model.Attachments
import dagger.Module
import dagger.Provides
import dagger.multibindings.IntoMap
import java.net.URLDecoder
-import java.nio.charset.Charset
import javax.inject.Named
@Module
@@ -74,12 +71,12 @@ class ComposeActivityModule {
@Provides
@Named("attachments")
- fun provideSharedAttachments(activity: ComposeActivity): Attachments {
+ fun provideSharedAttachments(activity: ComposeActivity): List {
val uris = mutableListOf()
activity.intent.getParcelableExtra(Intent.EXTRA_STREAM)?.run(uris::add)
activity.intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)?.run(uris::addAll)
- return Attachments(uris.mapNotNull { Attachment(activity, it) })
+ return uris.mapNotNull { Attachment(activity, it) }
}
@Provides
diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/AttachmentAdapter.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeAttachmentAdapter.kt
similarity index 99%
rename from presentation/src/main/java/com/moez/QKSMS/feature/compose/AttachmentAdapter.kt
rename to presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeAttachmentAdapter.kt
index 72759ea3..35a69f3f 100644
--- a/presentation/src/main/java/com/moez/QKSMS/feature/compose/AttachmentAdapter.kt
+++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeAttachmentAdapter.kt
@@ -41,7 +41,7 @@ import kotlinx.android.synthetic.main.attachment_contact_list_item.*
import javax.inject.Inject
-class AttachmentAdapter @Inject constructor(
+class ComposeAttachmentAdapter @Inject constructor(
private val context: Context
) : QkAdapter() {
diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeState.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeState.kt
index ca8f6475..fd261a00 100644
--- a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeState.kt
+++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeState.kt
@@ -40,11 +40,12 @@ data class ComposeState(
val messages: Pair>? = null,
val selectedMessages: Int = 0,
val scheduled: Long = 0,
- val attachments: List = ArrayList(),
+ val attachments: List = listOf(),
val attaching: Boolean = false,
val scheduling: Boolean = false,
val remaining: String = "",
val subscription: SubscriptionInfoCompat? = null,
val canSend: Boolean = false,
val validRecipientNumbers: Int = 1,
+ val audioMsgRecording: Boolean = false,
)
\ No newline at end of file
diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeView.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeView.kt
index ef537990..13a819fb 100644
--- a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeView.kt
+++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeView.kt
@@ -23,7 +23,9 @@ import android.view.MenuItem
import android.view.View
import androidx.annotation.StringRes
import androidx.core.view.inputmethod.InputContentInfoCompat
+import com.moez.QKSMS.common.QkMediaPlayer
import dev.octoshrimpy.quik.common.base.QkView
+import dev.octoshrimpy.quik.common.widget.MicInputCloudView
import dev.octoshrimpy.quik.model.Attachment
import dev.octoshrimpy.quik.model.Recipient
import io.reactivex.Observable
@@ -75,6 +77,15 @@ interface ComposeView : QkView {
val confirmDeleteIntent: Observable>
val messageLinkAskIntent: Observable
val speechRecogniserIntent: Observable<*>
+ val shadeIntent: Observable
+ val recordAnAudioMessage: Observable
+ val recordAudioAbort: Observable
+ val recordAudioAttach: Observable
+ val recordAudioPlayerPlayPause: Observable
+ val recordAudioPlayerConfigUI: Subject
+ val recordAudioPlayerVisible: Subject
+ val recordAudioStartStop: Subject
+ val recordAudioChronometer: Subject
fun clearSelection()
fun toggleSelectAll()
@@ -83,6 +94,7 @@ interface ComposeView : QkView {
fun showMessageLinkAskDialog(uri: Uri)
fun requestDefaultSms()
fun requestStoragePermission()
+ fun requestRecordAudioPermission()
fun requestSmsPermission()
fun showContacts(sharing: Boolean, chips: List)
fun themeChanged()
diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeViewModel.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeViewModel.kt
index b99423be..290fbf06 100644
--- a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeViewModel.kt
+++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeViewModel.kt
@@ -18,11 +18,18 @@
*/
package dev.octoshrimpy.quik.feature.compose
+import android.annotation.SuppressLint
import android.content.Context
+import android.media.AudioAttributes
+import android.net.Uri
import android.os.Vibrator
import android.telephony.SmsMessage
import androidx.core.content.getSystemService
+import androidx.core.net.toFile
+import androidx.core.net.toUri
+import com.moez.QKSMS.common.QkMediaPlayer
import com.moez.QKSMS.contentproviders.MmsPartProvider
+import com.moez.QKSMS.manager.MediaRecorderManager
import dev.octoshrimpy.quik.R
import dev.octoshrimpy.quik.common.Navigator
import dev.octoshrimpy.quik.common.base.QkViewModel
@@ -45,7 +52,6 @@ import dev.octoshrimpy.quik.manager.ActiveConversationManager
import dev.octoshrimpy.quik.manager.BillingManager
import dev.octoshrimpy.quik.manager.PermissionManager
import dev.octoshrimpy.quik.model.Attachment
-import dev.octoshrimpy.quik.model.Attachments
import dev.octoshrimpy.quik.model.Conversation
import dev.octoshrimpy.quik.model.Message
import dev.octoshrimpy.quik.model.Recipient
@@ -58,6 +64,7 @@ import dev.octoshrimpy.quik.util.Preferences
import dev.octoshrimpy.quik.util.tryOrNull
import com.uber.autodispose.android.lifecycle.scope
import com.uber.autodispose.autoDisposable
+import dev.octoshrimpy.quik.common.widget.MicInputCloudView
import dev.octoshrimpy.quik.common.widget.QkContextMenuRecyclerView
import dev.octoshrimpy.quik.extensions.isSmil
import dev.octoshrimpy.quik.interactor.SaveImage
@@ -72,7 +79,9 @@ import io.reactivex.subjects.BehaviorSubject
import io.reactivex.subjects.PublishSubject
import io.reactivex.subjects.Subject
import timber.log.Timber
+import java.io.File
import java.util.*
+import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Named
@@ -81,7 +90,7 @@ class ComposeViewModel @Inject constructor(
@Named("threadId") private val threadId: Long,
@Named("addresses") private val addresses: List,
@Named("text") private val sharedText: String,
- @Named("attachments") private val sharedAttachments: Attachments,
+ @Named("attachments") private val sharedAttachments: List,
@Named("mode") private val mode: String,
private val contactRepo: ContactRepository,
private val context: Context,
@@ -108,7 +117,10 @@ class ComposeViewModel @Inject constructor(
query = query)
) {
- private val attachments: Subject> = BehaviorSubject.createDefault(sharedAttachments)
+ companion object {
+ private const val AUDIO_FILE_PREFIX = "recorded-"
+ }
+
private val chipsReducer: Subject<(List) -> List> = PublishSubject.create()
private val conversation: Subject = BehaviorSubject.create()
private val messages: Subject> = BehaviorSubject.create()
@@ -119,6 +131,9 @@ class ComposeViewModel @Inject constructor(
private var shouldShowContacts = threadId == 0L && addresses.isEmpty()
init {
+ // set shared attachments into state, if any
+ newState { copy(attachments = sharedAttachments) }
+
val initialConversation = threadId.takeIf { it != 0L }
?.let(conversationRepo::getConversationAsync)
?.asObservable()
@@ -199,9 +214,6 @@ class ComposeViewModel @Inject constructor(
.distinctUntilChanged()
.subscribe { enabled -> newState { copy(sendAsGroup = enabled) } }
- disposables += attachments
- .subscribe { attachments -> newState { copy(attachments = attachments) } }
-
disposables += conversation
.map { conversation -> conversation.id }
.distinctUntilChanged()
@@ -253,10 +265,11 @@ class ComposeViewModel @Inject constructor(
newState { copy(scheduling = true) }
}
+ @SuppressLint("StringFormatInvalid")
override fun bindView(view: ComposeView) {
super.bindView(view)
- val sharing = sharedText.isNotEmpty() || sharedAttachments.isNotEmpty()
+ val sharing = (sharedText.isNotEmpty() || sharedAttachments.isNotEmpty())
if (shouldShowContacts) {
shouldShowContacts = false
view.showContacts(sharing, selectedChips.blockingFirst())
@@ -387,8 +400,10 @@ class ComposeViewModel @Inject constructor(
.filter { it == R.id.forward }
.withLatestFrom(view.messagesSelectedIntent) { _, messages ->
messages?.firstOrNull()?.let { messageRepo.getMessage(it) }?.let { message ->
- val attachments = message.parts.filter { !it.isSmil() }.mapNotNull { it.getUri() }
- navigator.showCompose(message.getText(), attachments)
+ navigator.showCompose(
+ message.getText(),
+ message.parts.filter { !it.isSmil() }.mapNotNull { it.getUri() }
+ )
}
}
.autoDisposable(view.scope())
@@ -648,15 +663,16 @@ class ComposeViewModel @Inject constructor(
.autoDisposable(view.scope())
.subscribe { view.requestDatePicker() }
- // a file, photo or otherwise, was picked by the user
+ // an attachment was picked by the user
Observable.merge(
view.attachAnyFileSelectedIntent.map { uri -> Attachment(context, uri) },
view.inputContentIntent.map { inputContent -> Attachment(context, inputContent = inputContent) }
)
- .withLatestFrom(attachments) { attachment, attachments -> attachments + attachment }
- .doOnNext(attachments::onNext)
.autoDisposable(view.scope())
- .subscribe { newState { copy(attaching = false) } }
+ .subscribe {
+ newState { copy(attachments = attachments + it, attaching = false) }
+ }
+
// Set the scheduled time
view.scheduleSelectedIntent
.filter { scheduled ->
@@ -675,20 +691,28 @@ class ComposeViewModel @Inject constructor(
// Contact was selected for attachment
view.contactSelectedIntent
- .map { uri -> Attachment(context, uri) }
- .withLatestFrom(attachments) { attachment, attachments -> attachments + attachment }
.subscribeOn(Schedulers.io())
.autoDisposable(view.scope())
- .subscribe(attachments::onNext) { error ->
+ .subscribe(
+ {
+ newState {
+ copy(attachments = attachments + Attachment(context, uri = it))
+ }
+ }
+ ) { error ->
context.makeToast(R.string.compose_contact_error)
Timber.w(error)
}
- // Detach a photo
+ // Detach an attachment
view.attachmentDeletedIntent
- .withLatestFrom(attachments) { bitmap, attachments -> attachments.filter { it !== bitmap } }
.autoDisposable(view.scope())
- .subscribe { attachments.onNext(it) }
+ .subscribe {
+ newState { copy(attachments = attachments - it) }
+
+ // if the attachment is backed by a local file, delete the file
+ it.removeCacheFile()
+ }
conversation
.map { conversation -> conversation.draft }
@@ -707,14 +731,17 @@ class ComposeViewModel @Inject constructor(
}
}
- // Enable the send button when there is text input into the new message body or there's
- // an attachment, disable otherwise
- Observables
- .combineLatest(view.textChangedIntent, attachments) { text, attachments ->
- text.isNotBlank() || attachments.isNotEmpty()
- }
- .autoDisposable(view.scope())
- .subscribe { canSend -> newState { copy(canSend = canSend) } }
+ // set canSend state depending on if there is text input or an attachment
+ Observables.combineLatest(
+ view.textChangedIntent, // input message text changed
+ state
+ .distinctUntilChanged { state -> state.attachments } // attachments changed
+ .map { it.attachments.size } // number of attachments
+ )
+ .autoDisposable(view.scope())
+ .subscribe {
+ newState { copy(canSend = (it.first.isNotBlank() || (it.second > 0))) }
+ }
// Show the remaining character counter when necessary
view.textChangedIntent
@@ -766,11 +793,140 @@ class ComposeViewModel @Inject constructor(
.autoDisposable(view.scope())
.subscribe { view.startSpeechRecognition() }
+ // shade clicked
+ view.shadeIntent
+ .autoDisposable(view.scope())
+ .subscribe { newState { copy(attaching = false) } }
+
+ // starting or stopping (change state) of audio message recording
+ state
+ .distinctUntilChanged { state -> state.audioMsgRecording }
+ .skip(1) // skip initial value
+ .autoDisposable(view.scope())
+ .subscribe {
+ // stop any audio playback (ie from mms attachment or audio recorder)
+ QkMediaPlayer.stop()
+
+ // if leaving audio recording mode
+ if (!it.audioMsgRecording) {
+ // ensure recording stopped and delete any recording file
+ safeDeleteLocalFile(MediaRecorderManager.stopRecording())
+ view.recordAudioChronometer.onNext(false) // stop chronometer
+ }
+ }
+
+ // start recording an audio message
+ view.recordAnAudioMessage
+ .autoDisposable(view.scope())
+ .subscribe {
+ view.recordAudioPlayerVisible.onNext(false) // hide audio player
+
+ // check have permissions to record audio
+ if (permissionManager.hasRecordAudio().also {
+ if (!it) view.requestRecordAudioPermission()
+ }) {
+ newState { copy( attaching = false, audioMsgRecording = true) }
+ view.recordAudioChronometer.onNext(true) // start chronometer
+ MediaRecorderManager.startRecording(context)
+ }
+ }
+
+ // abort recording audio message button
+ view.recordAudioAbort
+ .observeOn(Schedulers.io())
+ .autoDisposable(view.scope())
+ .subscribe { newState { copy( audioMsgRecording = false) } }
+
+ // main record/stop recording audio message button
+ view.recordAudioStartStop
+ .autoDisposable(view.scope())
+ .subscribe {
+ if (it == MicInputCloudView.ViewState.PAUSED_STATE) {
+ view.recordAudioChronometer.onNext(false) // stop chronometer
+ MediaRecorderManager.stopRecording()
+ view.recordAudioPlayerVisible.onNext(true) // show audio player
+ } else { // state = start recording
+ // delete old recording file
+ safeDeleteLocalFile(MediaRecorderManager.uri)
+ view.recordAudioPlayerVisible.onNext(false) // hide audio player
+ view.recordAudioChronometer.onNext(true) // start chronometer
+ MediaRecorderManager.startRecording(context)
+ }
+ }
+
+ // attach recorded audio message button
+ view.recordAudioAttach
+ .autoDisposable(view.scope())
+ .subscribe {
+ MediaRecorderManager.stopRecording()
+
+ try {
+ // create new filename for recorded file
+ val newFile = File(
+ context.cacheDir,
+ "${AUDIO_FILE_PREFIX}${UUID.randomUUID()}${MediaRecorderManager.AUDIO_FILE_SUFFIX}"
+ )
+
+ // rename recorded file to new name
+ MediaRecorderManager.uri.toFile().renameTo(newFile)
+
+ // attach newly named file to message
+ newState {
+ copy(
+ audioMsgRecording = false,
+ attachments = attachments + Attachment(context, newFile.toUri())
+ )
+ }
+ }
+ catch (e: Exception) { /* nothing */ }
+ }
+
+ // audio recording player play/pause button
+ view.recordAudioPlayerPlayPause
+ .autoDisposable(view.scope())
+ .subscribe {
+ when (it) {
+ QkMediaPlayer.PlayingState.Paused ->
+ view.recordAudioPlayerConfigUI.onNext(
+ QkMediaPlayer.PlayingState.Playing
+ )
+ QkMediaPlayer.PlayingState.Playing ->
+ view.recordAudioPlayerConfigUI.onNext(
+ QkMediaPlayer.PlayingState.Paused
+ )
+ else -> {
+ if (MediaRecorderManager.uri != Uri.EMPTY) {
+ QkMediaPlayer.setOnPreparedListener {
+ view.recordAudioPlayerConfigUI.onNext(
+ QkMediaPlayer.PlayingState.Playing
+ )
+ }
+ QkMediaPlayer.setOnCompletionListener {
+ view.recordAudioPlayerConfigUI.onNext(
+ QkMediaPlayer.PlayingState.Stopped
+ )
+ }
+
+ // start the media player play sequence
+ QkMediaPlayer.setAudioAttributes(
+ AudioAttributes.Builder()
+ .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
+ .setUsage(AudioAttributes.USAGE_MEDIA)
+ .build()
+ )
+
+ QkMediaPlayer.setDataSource(context, MediaRecorderManager.uri)
+
+ QkMediaPlayer.prepareAsync()
+ }
+ }
+ }
+ }
+
// Send a message when the send button is clicked, and disable editing mode if it's enabled
view.sendIntent
.withLatestFrom(view.textChangedIntent) { _, body -> body.toString() }
- .withLatestFrom(state, attachments, conversation, selectedChips) { body, state, attachments,
- conversation, chips ->
+ .withLatestFrom(state, conversation, selectedChips) { body, state, conversation, chips ->
if (!permissionManager.isDefaultSms()) {
view.requestDefaultSms()
return@withLatestFrom
@@ -804,7 +960,7 @@ class ComposeViewModel @Inject constructor(
// Scheduling a message
state.scheduled != 0L -> {
newState { copy(scheduled = 0) }
- val uris = attachments.map { it.uri.toString() }
+ val uris = state.attachments.map { it.uri.toString() }
val params = AddScheduledMessage
.Params(state.scheduled, subId, addresses, sendAsGroup, body, uris)
addScheduledMessage.execute(params)
@@ -814,19 +970,19 @@ class ComposeViewModel @Inject constructor(
// Sending a group message
sendAsGroup -> {
sendMessage.execute(SendMessage
- .Params(subId, conversation.id, addresses, body, attachments, delay))
+ .Params(subId, conversation.id, addresses, body, state.attachments, delay))
}
// Sending a message to an existing conversation with one recipient
conversation.recipients.size == 1 -> {
val address = conversation.recipients.map { it.address }
- sendMessage.execute(SendMessage.Params(subId, threadId, address, body, attachments, delay))
+ sendMessage.execute(SendMessage.Params(subId, threadId, address, body, state.attachments, delay))
}
// Create a new conversation with one address
addresses.size == 1 -> {
sendMessage.execute(SendMessage
- .Params(subId, threadId, addresses, body, attachments, delay))
+ .Params(subId, threadId, addresses, body, state.attachments, delay))
}
// Send a message to multiple addresses
@@ -838,13 +994,14 @@ class ComposeViewModel @Inject constructor(
val address = listOf(conversationRepo
.getConversation(threadId)?.recipients?.firstOrNull()?.address ?: addr)
sendMessage.execute(SendMessage
- .Params(subId, threadId, address, body, attachments, delay))
+ .Params(subId, threadId, address, body, state.attachments, delay))
}
}
}
+ // configure for new message
view.setDraft("")
- this.attachments.onNext(ArrayList())
+ newState { copy(attachments = listOf()) }
if (state.editingMode) {
newState { copy(editingMode = false, hasError = !sendAsGroup) }
@@ -880,6 +1037,28 @@ class ComposeViewModel @Inject constructor(
.autoDisposable(view.scope())
.subscribe { view.clearSelection() }
+ // when activity changes visibility, delete old recording cache files in background thread
+ // generally there won't be any, but under some circumstances some can be left behind
+ view.activityVisibleIntent
+ .subscribeOn(Schedulers.io())
+ .autoDisposable(view.scope())
+ .subscribe {
+ context.cacheDir.listFiles()?.forEach {
+ if (it.isFile &&
+ it.name.startsWith(AUDIO_FILE_PREFIX) &&
+ it.name.endsWith(MediaRecorderManager.AUDIO_FILE_SUFFIX))
+ safeDeleteLocalFile(it.toUri())
+ }
+ }
+ }
+
+ private fun safeDeleteLocalFile(uri: Uri): Boolean {
+ return try {
+ if (uri == Uri.EMPTY)
+ false
+ uri.toFile().delete()
+ }
+ catch (e: Exception) { false }
}
}
diff --git a/presentation/src/main/res/layout/compose_activity.xml b/presentation/src/main/res/layout/compose_activity.xml
index fb19ed9a..5bf8c05a 100644
--- a/presentation/src/main/res/layout/compose_activity.xml
+++ b/presentation/src/main/res/layout/compose_activity.xml
@@ -168,7 +168,7 @@
android:id="@+id/composeBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- app:constraint_referenced_ids="messageBackground,parts,attach,message,counter,send" />
+ app:constraint_referenced_ids="messageBackground,messageAttachments,attach,message,counter" />
+ tools:text="December 23rd at 12:00AM" />
+ app:layout_constraintEnd_toEndOf="@id/messageBackground"
+ app:tint="?android:attr/textColorSecondary" />
+ app:layout_constraintEnd_toEndOf="@id/messageBackground"
+ app:tint="?android:attr/textColorSecondary" />
+ app:layout_constraintEnd_toEndOf="parent"
+ app:tint="?android:textColorSecondary"
+ tools:visibility="visible" />
+ android:textDirection="ltr"
+ tools:text="Moez Bhatti" />
-
-
+ app:constraint_referenced_ids="contact,contactLabel,schedule,scheduleLabel,attachAFileIcon,attachAFileLabel,attachAnAudioMessageIcon,attachAnAudioMessageLabel,gallery,galleryLabel,camera,cameraLabel" />
+ app:layout_constraintTop_toTopOf="parent"
+ tools:visibility="gone" />
+ app:layout_constraintStart_toStartOf="@id/attach"
+ app:tint="?android:attr/textColorSecondary" />
+ app:layout_constraintStart_toStartOf="@id/attach"
+ app:tint="?android:attr/textColorSecondary" />
-
@@ -521,6 +518,39 @@
app:layout_constraintStart_toEndOf="@id/attachAFileIcon"
app:layout_constraintTop_toTopOf="@id/attachAFileIcon" />
+
+
+
+ app:layout_constraintStart_toStartOf="@id/attach"
+ app:tint="?android:attr/textColorSecondary" />
+ app:layout_constraintStart_toStartOf="@id/attach"
+ app:tint="?android:attr/textColorSecondary" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/presentation/src/main/res/values/attrs.xml b/presentation/src/main/res/values/attrs.xml
index 45f5c173..32dfbb87 100644
--- a/presentation/src/main/res/values/attrs.xml
+++ b/presentation/src/main/res/values/attrs.xml
@@ -70,4 +70,9 @@
+
+
+
+
+
diff --git a/presentation/src/main/res/values/strings.xml b/presentation/src/main/res/values/strings.xml
index d7371aaa..915409d8 100644
--- a/presentation/src/main/res/values/strings.xml
+++ b/presentation/src/main/res/values/strings.xml
@@ -146,14 +146,21 @@
Add an attachment
Attach any file
Attach a photo
+ Record an audio message
Take a photo
Schedule message
+ Cancel schedule message
Attach a contact
Error reading contact
SIM %1$d (%2$s) selected
%s selected, change SIM card
Send message
+ Record audio message
+
+ Cancel audio message
+ Attach audio message
+ Play or pause audio message
Cancel
Send now