From 94019f5e7aa5ce16ccbd359e53399fae4043b25b Mon Sep 17 00:00:00 2001 From: gavine99 Date: Mon, 17 Feb 2025 19:10:59 +1100 Subject: [PATCH] rebased to latest master --- data/src/main/AndroidManifest.xml | 2 + .../moez/QKSMS/extensions/UriExtensions.kt | 21 +- .../QKSMS/manager/MediaRecorderManager.kt | 95 ++++ .../QKSMS/manager/PermissionManagerImpl.kt | 4 + .../QKSMS/repository/MessageRepositoryImpl.kt | 27 +- .../com/moez/QKSMS/interactor/SendMessage.kt | 3 + .../moez/QKSMS/manager/PermissionManager.kt | 2 + .../java/com/moez/QKSMS/model/Attachment.kt | 78 +++- .../QKSMS/common/widget/MicInputCloudView.kt | 404 ++++++++++++++++++ .../QKSMS/feature/compose/ComposeActivity.kt | 203 ++++++++- .../feature/compose/ComposeActivityModule.kt | 7 +- ...Adapter.kt => ComposeAttachmentAdapter.kt} | 2 +- .../QKSMS/feature/compose/ComposeState.kt | 3 +- .../moez/QKSMS/feature/compose/ComposeView.kt | 12 + .../QKSMS/feature/compose/ComposeViewModel.kt | 249 +++++++++-- .../src/main/res/layout/compose_activity.xml | 227 ++++++++-- presentation/src/main/res/values/attrs.xml | 5 + presentation/src/main/res/values/strings.xml | 7 + 18 files changed, 1220 insertions(+), 131 deletions(-) create mode 100644 data/src/main/java/com/moez/QKSMS/manager/MediaRecorderManager.kt create mode 100644 presentation/src/main/java/com/moez/QKSMS/common/widget/MicInputCloudView.kt rename presentation/src/main/java/com/moez/QKSMS/feature/compose/{AttachmentAdapter.kt => ComposeAttachmentAdapter.kt} (99%) 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