Merge branch 'dev' of https://github.com/loki-project/session-android into backup-restore
commit
82b87d9178
@ -0,0 +1,3 @@
|
||||
-dontwarn java.awt.*
|
||||
-keep class com.sun.jna.* { *; }
|
||||
-keepclassmembers class * extends com.sun.jna.* { public *; }
|
@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<inset xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:drawable="@drawable/circle_tintable"
|
||||
android:inset="4dp"/>
|
@ -1,10 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<org.thoughtcrime.securesms.components.AudioView
|
||||
<org.thoughtcrime.securesms.loki.views.MessageAudioView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/audio_view"
|
||||
android:layout_width="210dp"
|
||||
android:layout_height="wrap_content"
|
||||
app:foregroundTintColor="?android:colorControlNormal"
|
||||
app:backgroundTintColor="?message_sent_background_color"
|
||||
app:waveformFillColor="?conversation_item_audio_seek_bar_color_outgoing"
|
||||
app:waveformBackgroundColor="?conversation_item_audio_seek_bar_background_color"
|
||||
android:visibility="gone"/>
|
||||
|
@ -0,0 +1,23 @@
|
||||
package org.thoughtcrime.securesms.attachments
|
||||
|
||||
data class DatabaseAttachmentAudioExtras(
|
||||
val attachmentId: AttachmentId,
|
||||
/** Small amount of normalized audio byte samples to visualise the content (e.g. draw waveform). */
|
||||
val visualSamples: ByteArray,
|
||||
/** Duration of the audio track in milliseconds. May be [DURATION_UNDEFINED] when it is not known. */
|
||||
val durationMs: Long = DURATION_UNDEFINED) {
|
||||
|
||||
companion object {
|
||||
const val DURATION_UNDEFINED = -1L
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
return other != null &&
|
||||
other is DatabaseAttachmentAudioExtras &&
|
||||
other.attachmentId == attachmentId
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return attachmentId.hashCode()
|
||||
}
|
||||
}
|
@ -1,330 +0,0 @@
|
||||
package org.thoughtcrime.securesms.components;
|
||||
|
||||
import android.annotation.TargetApi;
|
||||
import android.content.Context;
|
||||
import android.content.res.ColorStateList;
|
||||
import android.content.res.TypedArray;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.PorterDuff;
|
||||
import android.graphics.drawable.AnimatedVectorDrawable;
|
||||
import android.os.Build;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.SeekBar;
|
||||
import android.widget.TextView;
|
||||
|
||||
import com.pnikosis.materialishprogress.ProgressWheel;
|
||||
|
||||
import org.greenrobot.eventbus.EventBus;
|
||||
import org.greenrobot.eventbus.Subscribe;
|
||||
import org.greenrobot.eventbus.ThreadMode;
|
||||
import org.thoughtcrime.securesms.audio.AudioSlidePlayer;
|
||||
import org.thoughtcrime.securesms.database.AttachmentDatabase;
|
||||
import org.thoughtcrime.securesms.events.PartProgressEvent;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.mms.AudioSlide;
|
||||
import org.thoughtcrime.securesms.mms.SlideClickListener;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import network.loki.messenger.R;
|
||||
|
||||
|
||||
public class AudioView extends FrameLayout implements AudioSlidePlayer.Listener {
|
||||
|
||||
private static final String TAG = AudioView.class.getSimpleName();
|
||||
|
||||
private final @NonNull AnimatingToggle controlToggle;
|
||||
private final @NonNull ViewGroup container;
|
||||
private final @NonNull ImageView playButton;
|
||||
private final @NonNull ImageView pauseButton;
|
||||
private final @NonNull ImageView downloadButton;
|
||||
private final @NonNull ProgressWheel downloadProgress;
|
||||
private final @NonNull SeekBar seekBar;
|
||||
private final @NonNull TextView timestamp;
|
||||
|
||||
private @Nullable SlideClickListener downloadListener;
|
||||
private @Nullable AudioSlidePlayer audioSlidePlayer;
|
||||
private int backwardsCounter;
|
||||
|
||||
public AudioView(Context context) {
|
||||
this(context, null);
|
||||
}
|
||||
|
||||
public AudioView(Context context, AttributeSet attrs) {
|
||||
this(context, attrs, 0);
|
||||
}
|
||||
|
||||
public AudioView(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
inflate(context, R.layout.audio_view, this);
|
||||
|
||||
this.container = (ViewGroup) findViewById(R.id.audio_widget_container);
|
||||
this.controlToggle = (AnimatingToggle) findViewById(R.id.control_toggle);
|
||||
this.playButton = (ImageView) findViewById(R.id.play);
|
||||
this.pauseButton = (ImageView) findViewById(R.id.pause);
|
||||
this.downloadButton = (ImageView) findViewById(R.id.download);
|
||||
this.downloadProgress = (ProgressWheel) findViewById(R.id.download_progress);
|
||||
this.seekBar = (SeekBar) findViewById(R.id.seek);
|
||||
this.timestamp = (TextView) findViewById(R.id.timestamp);
|
||||
|
||||
this.playButton.setOnClickListener(new PlayClickedListener());
|
||||
this.pauseButton.setOnClickListener(new PauseClickedListener());
|
||||
this.seekBar.setOnSeekBarChangeListener(new SeekBarModifiedListener());
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
this.playButton.setImageDrawable(context.getDrawable(R.drawable.play_icon));
|
||||
this.pauseButton.setImageDrawable(context.getDrawable(R.drawable.pause_icon));
|
||||
this.playButton.setBackground(context.getDrawable(R.drawable.ic_circle_fill_white_48dp));
|
||||
this.pauseButton.setBackground(context.getDrawable(R.drawable.ic_circle_fill_white_48dp));
|
||||
}
|
||||
|
||||
if (attrs != null) {
|
||||
TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.AudioView, 0, 0);
|
||||
setTint(typedArray.getColor(R.styleable.AudioView_foregroundTintColor, Color.WHITE),
|
||||
typedArray.getColor(R.styleable.AudioView_backgroundTintColor, Color.WHITE));
|
||||
container.setBackgroundColor(typedArray.getColor(R.styleable.AudioView_widgetBackground, Color.TRANSPARENT));
|
||||
typedArray.recycle();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onAttachedToWindow() {
|
||||
super.onAttachedToWindow();
|
||||
if (!EventBus.getDefault().isRegistered(this)) EventBus.getDefault().register(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow();
|
||||
EventBus.getDefault().unregister(this);
|
||||
}
|
||||
|
||||
public void setAudio(final @NonNull AudioSlide audio,
|
||||
final boolean showControls)
|
||||
{
|
||||
|
||||
if (showControls && audio.isPendingDownload()) {
|
||||
controlToggle.displayQuick(downloadButton);
|
||||
seekBar.setEnabled(false);
|
||||
downloadButton.setOnClickListener(new DownloadClickedListener(audio));
|
||||
if (downloadProgress.isSpinning()) downloadProgress.stopSpinning();
|
||||
} else if (showControls && audio.getTransferState() == AttachmentDatabase.TRANSFER_PROGRESS_STARTED) {
|
||||
controlToggle.displayQuick(downloadProgress);
|
||||
seekBar.setEnabled(false);
|
||||
downloadProgress.spin();
|
||||
} else {
|
||||
controlToggle.displayQuick(playButton);
|
||||
seekBar.setEnabled(true);
|
||||
if (downloadProgress.isSpinning()) downloadProgress.stopSpinning();
|
||||
}
|
||||
|
||||
this.audioSlidePlayer = AudioSlidePlayer.createFor(getContext(), audio, this);
|
||||
}
|
||||
|
||||
public void cleanup() {
|
||||
if (this.audioSlidePlayer != null && pauseButton.getVisibility() == View.VISIBLE) {
|
||||
this.audioSlidePlayer.stop();
|
||||
}
|
||||
}
|
||||
|
||||
public void setDownloadClickListener(@Nullable SlideClickListener listener) {
|
||||
this.downloadListener = listener;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStart() {
|
||||
if (this.pauseButton.getVisibility() != View.VISIBLE) {
|
||||
togglePlayToPause();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStop() {
|
||||
if (this.playButton.getVisibility() != View.VISIBLE) {
|
||||
togglePauseToPlay();
|
||||
}
|
||||
|
||||
if (seekBar.getProgress() + 5 >= seekBar.getMax()) {
|
||||
backwardsCounter = 4;
|
||||
onProgress(0.0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setFocusable(boolean focusable) {
|
||||
super.setFocusable(focusable);
|
||||
this.playButton.setFocusable(focusable);
|
||||
this.pauseButton.setFocusable(focusable);
|
||||
this.seekBar.setFocusable(focusable);
|
||||
this.seekBar.setFocusableInTouchMode(focusable);
|
||||
this.downloadButton.setFocusable(focusable);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setClickable(boolean clickable) {
|
||||
super.setClickable(clickable);
|
||||
this.playButton.setClickable(clickable);
|
||||
this.pauseButton.setClickable(clickable);
|
||||
this.seekBar.setClickable(clickable);
|
||||
this.seekBar.setOnTouchListener(clickable ? null : new TouchIgnoringListener());
|
||||
this.downloadButton.setClickable(clickable);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setEnabled(boolean enabled) {
|
||||
super.setEnabled(enabled);
|
||||
this.playButton.setEnabled(enabled);
|
||||
this.pauseButton.setEnabled(enabled);
|
||||
this.seekBar.setEnabled(enabled);
|
||||
this.downloadButton.setEnabled(enabled);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onProgress(double progress, long millis) {
|
||||
int seekProgress = (int)Math.floor(progress * this.seekBar.getMax());
|
||||
|
||||
if (seekProgress > seekBar.getProgress() || backwardsCounter > 3) {
|
||||
backwardsCounter = 0;
|
||||
this.seekBar.setProgress(seekProgress);
|
||||
this.timestamp.setText(String.format("%02d:%02d",
|
||||
TimeUnit.MILLISECONDS.toMinutes(millis),
|
||||
TimeUnit.MILLISECONDS.toSeconds(millis)));
|
||||
} else {
|
||||
backwardsCounter++;
|
||||
}
|
||||
}
|
||||
|
||||
public void setTint(int foregroundTint, int backgroundTint) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
this.playButton.setBackgroundTintList(ColorStateList.valueOf(foregroundTint));
|
||||
this.playButton.setImageTintList(ColorStateList.valueOf(backgroundTint));
|
||||
this.pauseButton.setBackgroundTintList(ColorStateList.valueOf(foregroundTint));
|
||||
this.pauseButton.setImageTintList(ColorStateList.valueOf(backgroundTint));
|
||||
} else {
|
||||
this.playButton.setColorFilter(foregroundTint, PorterDuff.Mode.SRC_IN);
|
||||
this.pauseButton.setColorFilter(foregroundTint, PorterDuff.Mode.SRC_IN);
|
||||
}
|
||||
|
||||
this.downloadButton.setColorFilter(foregroundTint, PorterDuff.Mode.SRC_IN);
|
||||
this.downloadProgress.setBarColor(foregroundTint);
|
||||
|
||||
this.timestamp.setTextColor(foregroundTint);
|
||||
this.seekBar.getProgressDrawable().setColorFilter(foregroundTint, PorterDuff.Mode.SRC_IN);
|
||||
this.seekBar.getThumb().setColorFilter(foregroundTint, PorterDuff.Mode.SRC_IN);
|
||||
}
|
||||
|
||||
private double getProgress() {
|
||||
if (this.seekBar.getProgress() <= 0 || this.seekBar.getMax() <= 0) {
|
||||
return 0;
|
||||
} else {
|
||||
return (double)this.seekBar.getProgress() / (double)this.seekBar.getMax();
|
||||
}
|
||||
}
|
||||
|
||||
private void togglePlayToPause() {
|
||||
controlToggle.displayQuick(pauseButton);
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
AnimatedVectorDrawable playToPauseDrawable = (AnimatedVectorDrawable)getContext().getDrawable(R.drawable.play_to_pause_animation);
|
||||
pauseButton.setImageDrawable(playToPauseDrawable);
|
||||
playToPauseDrawable.start();
|
||||
}
|
||||
}
|
||||
|
||||
private void togglePauseToPlay() {
|
||||
controlToggle.displayQuick(playButton);
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
AnimatedVectorDrawable pauseToPlayDrawable = (AnimatedVectorDrawable)getContext().getDrawable(R.drawable.pause_to_play_animation);
|
||||
playButton.setImageDrawable(pauseToPlayDrawable);
|
||||
pauseToPlayDrawable.start();
|
||||
}
|
||||
}
|
||||
|
||||
private class PlayClickedListener implements View.OnClickListener {
|
||||
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
try {
|
||||
Log.d(TAG, "playbutton onClick");
|
||||
if (audioSlidePlayer != null) {
|
||||
togglePlayToPause();
|
||||
audioSlidePlayer.play(getProgress());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class PauseClickedListener implements View.OnClickListener {
|
||||
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
Log.d(TAG, "pausebutton onClick");
|
||||
if (audioSlidePlayer != null) {
|
||||
togglePauseToPlay();
|
||||
audioSlidePlayer.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class DownloadClickedListener implements View.OnClickListener {
|
||||
private final @NonNull AudioSlide slide;
|
||||
|
||||
private DownloadClickedListener(@NonNull AudioSlide slide) {
|
||||
this.slide = slide;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
if (downloadListener != null) downloadListener.onClick(v, slide);
|
||||
}
|
||||
}
|
||||
|
||||
private class SeekBarModifiedListener implements SeekBar.OnSeekBarChangeListener {
|
||||
@Override
|
||||
public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {}
|
||||
|
||||
@Override
|
||||
public synchronized void onStartTrackingTouch(SeekBar seekBar) {
|
||||
if (audioSlidePlayer != null && pauseButton.getVisibility() == View.VISIBLE) {
|
||||
audioSlidePlayer.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void onStopTrackingTouch(SeekBar seekBar) {
|
||||
try {
|
||||
if (audioSlidePlayer != null && pauseButton.getVisibility() == View.VISIBLE) {
|
||||
audioSlidePlayer.play(getProgress());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static class TouchIgnoringListener implements OnTouchListener {
|
||||
@Override
|
||||
public boolean onTouch(View v, MotionEvent event) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe(sticky = true, threadMode = ThreadMode.MAIN)
|
||||
public void onEventAsync(final PartProgressEvent event) {
|
||||
if (audioSlidePlayer != null && event.attachment.equals(audioSlidePlayer.getAudioSlide().asAttachment())) {
|
||||
downloadProgress.setInstantProgress(((float) event.progress) / event.total);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,167 @@
|
||||
package org.thoughtcrime.securesms.loki.api
|
||||
|
||||
import android.media.MediaDataSource
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import org.greenrobot.eventbus.EventBus
|
||||
import org.thoughtcrime.securesms.attachments.Attachment
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachmentAudioExtras
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory
|
||||
import org.thoughtcrime.securesms.jobmanager.Data
|
||||
import org.thoughtcrime.securesms.jobmanager.Job
|
||||
import org.thoughtcrime.securesms.jobs.BaseJob
|
||||
import org.thoughtcrime.securesms.loki.utilities.audio.DecodedAudio
|
||||
import org.thoughtcrime.securesms.mms.PartAuthority
|
||||
import java.io.InputStream
|
||||
import java.lang.IllegalStateException
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Decodes the audio content of the related attachment entry
|
||||
* and caches the result with [DatabaseAttachmentAudioExtras] data.
|
||||
*
|
||||
* It only process attachments with "audio" mime types.
|
||||
*
|
||||
* Due to [DecodedAudio] implementation limitations, it only works for API 23+.
|
||||
* For any lower targets fake data will be generated.
|
||||
*
|
||||
* You can subscribe to [AudioExtrasUpdatedEvent] to be notified about the successful result.
|
||||
*/
|
||||
//TODO AC: Rewrite to WorkManager API when
|
||||
// https://github.com/loki-project/session-android/pull/354 is merged.
|
||||
class PrepareAttachmentAudioExtrasJob : BaseJob {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AttachAudioExtrasJob"
|
||||
|
||||
const val KEY = "PrepareAttachmentAudioExtrasJob"
|
||||
const val DATA_ATTACH_ID = "attachment_id"
|
||||
|
||||
const val VISUAL_RMS_FRAMES = 32 // The amount of values to be computed for the visualization.
|
||||
}
|
||||
|
||||
private val attachmentId: AttachmentId
|
||||
|
||||
constructor(attachmentId: AttachmentId) : this(Parameters.Builder()
|
||||
.setQueue(KEY)
|
||||
.setLifespan(TimeUnit.DAYS.toMillis(1))
|
||||
.build(),
|
||||
attachmentId)
|
||||
|
||||
private constructor(parameters: Parameters, attachmentId: AttachmentId) : super(parameters) {
|
||||
this.attachmentId = attachmentId
|
||||
}
|
||||
|
||||
override fun serialize(): Data {
|
||||
return Data.Builder().putParcelable(DATA_ATTACH_ID, attachmentId).build();
|
||||
}
|
||||
|
||||
override fun getFactoryKey(): String { return KEY
|
||||
}
|
||||
|
||||
override fun onShouldRetry(e: Exception): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onCanceled() { }
|
||||
|
||||
override fun onRun() {
|
||||
Log.v(TAG, "Processing attachment: $attachmentId")
|
||||
|
||||
val attachDb = DatabaseFactory.getAttachmentDatabase(context)
|
||||
val attachment = attachDb.getAttachment(attachmentId)
|
||||
|
||||
if (attachment == null) {
|
||||
throw IllegalStateException("Cannot find attachment with the ID $attachmentId")
|
||||
}
|
||||
if (!attachment.contentType.startsWith("audio/")) {
|
||||
throw IllegalStateException("Attachment $attachmentId is not of audio type.")
|
||||
}
|
||||
|
||||
// Check if the audio extras already exist.
|
||||
if (attachDb.getAttachmentAudioExtras(attachmentId) != null) return
|
||||
|
||||
fun extractAttachmentRandomSeed(attachment: Attachment): Int {
|
||||
return when {
|
||||
attachment.digest != null -> attachment.digest!!.sum()
|
||||
attachment.fileName != null -> attachment.fileName.hashCode()
|
||||
else -> attachment.hashCode()
|
||||
}
|
||||
}
|
||||
|
||||
fun generateFakeRms(seed: Int, frames: Int = VISUAL_RMS_FRAMES): ByteArray {
|
||||
return ByteArray(frames).apply { Random(seed.toLong()).nextBytes(this) }
|
||||
}
|
||||
|
||||
var rmsValues: ByteArray
|
||||
var totalDurationMs: Long = DatabaseAttachmentAudioExtras.DURATION_UNDEFINED
|
||||
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
|
||||
// Due to API version incompatibility, we just display some random waveform for older API.
|
||||
rmsValues = generateFakeRms(extractAttachmentRandomSeed(attachment))
|
||||
} else {
|
||||
try {
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
val decodedAudio = PartAuthority.getAttachmentStream(context, attachment.dataUri!!).use {
|
||||
DecodedAudio.create(InputStreamMediaDataSource(it))
|
||||
}
|
||||
rmsValues = decodedAudio.calculateRms(VISUAL_RMS_FRAMES)
|
||||
totalDurationMs = (decodedAudio.totalDuration / 1000.0).toLong()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to decode sample values for the audio attachment \"${attachment.fileName}\".", e)
|
||||
rmsValues = generateFakeRms(extractAttachmentRandomSeed(attachment))
|
||||
}
|
||||
}
|
||||
|
||||
attachDb.setAttachmentAudioExtras(DatabaseAttachmentAudioExtras(
|
||||
attachmentId,
|
||||
rmsValues,
|
||||
totalDurationMs
|
||||
))
|
||||
|
||||
EventBus.getDefault().post(AudioExtrasUpdatedEvent(attachmentId))
|
||||
}
|
||||
|
||||
class Factory : Job.Factory<PrepareAttachmentAudioExtrasJob> {
|
||||
override fun create(parameters: Parameters, data: Data): PrepareAttachmentAudioExtrasJob {
|
||||
return PrepareAttachmentAudioExtrasJob(parameters, data.getParcelable(DATA_ATTACH_ID, AttachmentId.CREATOR))
|
||||
}
|
||||
}
|
||||
|
||||
/** Gets dispatched once the audio extras have been updated. */
|
||||
data class AudioExtrasUpdatedEvent(val attachmentId: AttachmentId)
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private class InputStreamMediaDataSource: MediaDataSource {
|
||||
|
||||
private val data: ByteArray
|
||||
|
||||
constructor(inputStream: InputStream): super() {
|
||||
this.data = inputStream.readBytes()
|
||||
}
|
||||
|
||||
override fun readAt(position: Long, buffer: ByteArray, offset: Int, size: Int): Int {
|
||||
val length: Int = data.size
|
||||
if (position >= length) {
|
||||
return -1 // -1 indicates EOF
|
||||
}
|
||||
var actualSize = size
|
||||
if (position + size > length) {
|
||||
actualSize -= (position + size - length).toInt()
|
||||
}
|
||||
System.arraycopy(data, position.toInt(), buffer, offset, actualSize)
|
||||
return actualSize
|
||||
}
|
||||
|
||||
override fun getSize(): Long {
|
||||
return data.size.toLong()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
// We don't need to close the wrapped stream.
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,368 @@
|
||||
package org.thoughtcrime.securesms.loki.utilities.audio
|
||||
|
||||
import android.media.AudioFormat
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaDataSource
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import android.os.Build
|
||||
|
||||
import androidx.annotation.RequiresApi
|
||||
|
||||
import java.io.FileDescriptor
|
||||
import java.io.IOException
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.nio.ShortBuffer
|
||||
import kotlin.jvm.Throws
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Decodes the audio data and provides access to its sample data.
|
||||
* We need this to extract RMS values for waveform visualization.
|
||||
*
|
||||
* Use static [DecodedAudio.create] methods to instantiate a [DecodedAudio].
|
||||
*
|
||||
* Partially based on the old [Google's Ringdroid project]
|
||||
* (https://github.com/google/ringdroid/blob/master/app/src/main/java/com/ringdroid/soundfile/SoundFile.java).
|
||||
*
|
||||
* *NOTE:* This class instance creation might be pretty slow (depends on the source audio file size).
|
||||
* It's recommended to instantiate it in the background.
|
||||
*/
|
||||
@Suppress("MemberVisibilityCanBePrivate")
|
||||
class DecodedAudio {
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
@Throws(IOException::class)
|
||||
fun create(fd: FileDescriptor, startOffset: Long, size: Long): DecodedAudio {
|
||||
val mediaExtractor = MediaExtractor().apply { setDataSource(fd, startOffset, size) }
|
||||
return DecodedAudio(mediaExtractor, size)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@RequiresApi(api = Build.VERSION_CODES.M)
|
||||
@Throws(IOException::class)
|
||||
fun create(dataSource: MediaDataSource): DecodedAudio {
|
||||
val mediaExtractor = MediaExtractor().apply { setDataSource(dataSource) }
|
||||
return DecodedAudio(mediaExtractor, dataSource.size)
|
||||
}
|
||||
}
|
||||
|
||||
val dataSize: Long
|
||||
|
||||
/** Average bit rate in kbps. */
|
||||
val avgBitRate: Int
|
||||
|
||||
val sampleRate: Int
|
||||
|
||||
/** In microseconds. */
|
||||
val totalDuration: Long
|
||||
|
||||
val channels: Int
|
||||
|
||||
/** Total number of samples per channel in audio file. */
|
||||
val numSamples: Int
|
||||
|
||||
val samples: ShortBuffer
|
||||
get() {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
|
||||
Build.VERSION.SDK_INT <= Build.VERSION_CODES.N_MR1
|
||||
) {
|
||||
// Hack for Nougat where asReadOnlyBuffer fails to respect byte ordering.
|
||||
// See https://code.google.com/p/android/issues/detail?id=223824
|
||||
decodedSamples
|
||||
} else {
|
||||
decodedSamples.asReadOnlyBuffer()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared buffer with mDecodedBytes.
|
||||
* Has the following format:
|
||||
* {s1c1, s1c2, ..., s1cM, s2c1, ..., s2cM, ..., sNc1, ..., sNcM}
|
||||
* where sicj is the ith sample of the jth channel (a sample is a signed short)
|
||||
* M is the number of channels (e.g. 2 for stereo) and N is the number of samples per channel.
|
||||
*/
|
||||
private val decodedSamples: ShortBuffer
|
||||
|
||||
@Throws(IOException::class)
|
||||
private constructor(extractor: MediaExtractor, size: Long) {
|
||||
dataSize = size
|
||||
|
||||
var mediaFormat: MediaFormat? = null
|
||||
// Find and select the first audio track present in the file.
|
||||
for (trackIndex in 0 until extractor.trackCount) {
|
||||
val format = extractor.getTrackFormat(trackIndex)
|
||||
if (format.getString(MediaFormat.KEY_MIME)!!.startsWith("audio/")) {
|
||||
extractor.selectTrack(trackIndex)
|
||||
mediaFormat = format
|
||||
break
|
||||
}
|
||||
}
|
||||
if (mediaFormat == null) {
|
||||
throw IOException("No audio track found in the data source.")
|
||||
}
|
||||
|
||||
channels = mediaFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
|
||||
sampleRate = mediaFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE)
|
||||
// On some old APIs (23) this field might be missing.
|
||||
totalDuration = if (mediaFormat.containsKey(MediaFormat.KEY_DURATION)) {
|
||||
mediaFormat.getLong(MediaFormat.KEY_DURATION)
|
||||
} else {
|
||||
-1L
|
||||
}
|
||||
|
||||
// Expected total number of samples per channel.
|
||||
val expectedNumSamples = if (totalDuration >= 0) {
|
||||
((totalDuration / 1000000f) * sampleRate + 0.5f).toInt()
|
||||
} else {
|
||||
Int.MAX_VALUE
|
||||
}
|
||||
|
||||
val codec = MediaCodec.createDecoderByType(mediaFormat.getString(MediaFormat.KEY_MIME)!!)
|
||||
codec.configure(mediaFormat, null, null, 0)
|
||||
codec.start()
|
||||
|
||||
// Check if the track is in PCM 16 bit encoding.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
try {
|
||||
val pcmEncoding = codec.outputFormat.getInteger(MediaFormat.KEY_PCM_ENCODING)
|
||||
if (pcmEncoding != AudioFormat.ENCODING_PCM_16BIT) {
|
||||
throw IOException("Unsupported PCM encoding code: $pcmEncoding")
|
||||
}
|
||||
} catch (e: NullPointerException) {
|
||||
// If KEY_PCM_ENCODING is not specified, means it's ENCODING_PCM_16BIT.
|
||||
}
|
||||
}
|
||||
|
||||
var decodedSamplesSize: Int = 0 // size of the output buffer containing decoded samples.
|
||||
var decodedSamples: ByteArray? = null
|
||||
var sampleSize: Int
|
||||
val info = MediaCodec.BufferInfo()
|
||||
var presentationTime: Long
|
||||
var totalSizeRead: Int = 0
|
||||
var doneReading = false
|
||||
|
||||
// Set the size of the decoded samples buffer to 1MB (~6sec of a stereo stream at 44.1kHz).
|
||||
// For longer streams, the buffer size will be increased later on, calculating a rough
|
||||
// estimate of the total size needed to store all the samples in order to resize the buffer
|
||||
// only once.
|
||||
var decodedBytes: ByteBuffer = ByteBuffer.allocate(1 shl 20)
|
||||
var firstSampleData = true
|
||||
while (true) {
|
||||
// read data from file and feed it to the decoder input buffers.
|
||||
val inputBufferIndex: Int = codec.dequeueInputBuffer(100)
|
||||
if (!doneReading && inputBufferIndex >= 0) {
|
||||
sampleSize = extractor.readSampleData(codec.getInputBuffer(inputBufferIndex)!!, 0)
|
||||
if (firstSampleData
|
||||
&& mediaFormat.getString(MediaFormat.KEY_MIME)!! == "audio/mp4a-latm"
|
||||
&& sampleSize == 2
|
||||
) {
|
||||
// For some reasons on some devices (e.g. the Samsung S3) you should not
|
||||
// provide the first two bytes of an AAC stream, otherwise the MediaCodec will
|
||||
// crash. These two bytes do not contain music data but basic info on the
|
||||
// stream (e.g. channel configuration and sampling frequency), and skipping them
|
||||
// seems OK with other devices (MediaCodec has already been configured and
|
||||
// already knows these parameters).
|
||||
extractor.advance()
|
||||
totalSizeRead += sampleSize
|
||||
} else if (sampleSize < 0) {
|
||||
// All samples have been read.
|
||||
codec.queueInputBuffer(
|
||||
inputBufferIndex, 0, 0, -1, MediaCodec.BUFFER_FLAG_END_OF_STREAM
|
||||
)
|
||||
doneReading = true
|
||||
} else {
|
||||
presentationTime = extractor.sampleTime
|
||||
codec.queueInputBuffer(inputBufferIndex, 0, sampleSize, presentationTime, 0)
|
||||
extractor.advance()
|
||||
totalSizeRead += sampleSize
|
||||
}
|
||||
firstSampleData = false
|
||||
}
|
||||
|
||||
// Get decoded stream from the decoder output buffers.
|
||||
val outputBufferIndex: Int = codec.dequeueOutputBuffer(info, 100)
|
||||
if (outputBufferIndex >= 0 && info.size > 0) {
|
||||
if (decodedSamplesSize < info.size) {
|
||||
decodedSamplesSize = info.size
|
||||
decodedSamples = ByteArray(decodedSamplesSize)
|
||||
}
|
||||
val outputBuffer: ByteBuffer = codec.getOutputBuffer(outputBufferIndex)!!
|
||||
outputBuffer.get(decodedSamples!!, 0, info.size)
|
||||
outputBuffer.clear()
|
||||
// Check if buffer is big enough. Resize it if it's too small.
|
||||
if (decodedBytes.remaining() < info.size) {
|
||||
// Getting a rough estimate of the total size, allocate 20% more, and
|
||||
// make sure to allocate at least 5MB more than the initial size.
|
||||
val position = decodedBytes.position()
|
||||
var newSize = ((position * (1.0 * dataSize / totalSizeRead)) * 1.2).toInt()
|
||||
if (newSize - position < info.size + 5 * (1 shl 20)) {
|
||||
newSize = position + info.size + 5 * (1 shl 20)
|
||||
}
|
||||
var newDecodedBytes: ByteBuffer? = null
|
||||
// Try to allocate memory. If we are OOM, try to run the garbage collector.
|
||||
var retry = 10
|
||||
while (retry > 0) {
|
||||
try {
|
||||
newDecodedBytes = ByteBuffer.allocate(newSize)
|
||||
break
|
||||
} catch (e: OutOfMemoryError) {
|
||||
// setting android:largeHeap="true" in <application> seem to help not
|
||||
// reaching this section.
|
||||
retry--
|
||||
}
|
||||
}
|
||||
if (retry == 0) {
|
||||
// Failed to allocate memory... Stop reading more data and finalize the
|
||||
// instance with the data decoded so far.
|
||||
break
|
||||
}
|
||||
decodedBytes.rewind()
|
||||
newDecodedBytes!!.put(decodedBytes)
|
||||
decodedBytes = newDecodedBytes
|
||||
decodedBytes.position(position)
|
||||
}
|
||||
decodedBytes.put(decodedSamples, 0, info.size)
|
||||
codec.releaseOutputBuffer(outputBufferIndex, false)
|
||||
}
|
||||
|
||||
if ((info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0
|
||||
|| (decodedBytes.position() / (2 * channels)) >= expectedNumSamples
|
||||
) {
|
||||
// We got all the decoded data from the decoder. Stop here.
|
||||
// Theoretically dequeueOutputBuffer(info, ...) should have set info.flags to
|
||||
// MediaCodec.BUFFER_FLAG_END_OF_STREAM. However some phones (e.g. Samsung S3)
|
||||
// won't do that for some files (e.g. with mono AAC files), in which case subsequent
|
||||
// calls to dequeueOutputBuffer may result in the application crashing, without
|
||||
// even an exception being thrown... Hence the second check.
|
||||
// (for mono AAC files, the S3 will actually double each sample, as if the stream
|
||||
// was stereo. The resulting stream is half what it's supposed to be and with a much
|
||||
// lower pitch.)
|
||||
break
|
||||
}
|
||||
}
|
||||
numSamples = decodedBytes.position() / (channels * 2) // One sample = 2 bytes.
|
||||
decodedBytes.rewind()
|
||||
decodedBytes.order(ByteOrder.LITTLE_ENDIAN)
|
||||
this.decodedSamples = decodedBytes.asShortBuffer()
|
||||
avgBitRate = ((dataSize * 8) * (sampleRate.toFloat() / numSamples) / 1000).toInt()
|
||||
|
||||
extractor.release()
|
||||
codec.stop()
|
||||
codec.release()
|
||||
}
|
||||
|
||||
fun calculateRms(maxFrames: Int): ByteArray {
|
||||
return calculateRms(this.samples, this.numSamples, this.channels, maxFrames)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes audio RMS values for the first channel only.
|
||||
*
|
||||
* A typical RMS calculation algorithm is:
|
||||
* 1. Square each sample
|
||||
* 2. Sum the squared samples
|
||||
* 3. Divide the sum of the squared samples by the number of samples
|
||||
* 4. Take the square root of step 3., the mean of the squared samples
|
||||
*
|
||||
* @param maxFrames Defines amount of output RMS frames.
|
||||
* If number of samples per channel is less than "maxFrames",
|
||||
* the result array will match the source sample size instead.
|
||||
*
|
||||
* @return normalized RMS values as a signed byte array.
|
||||
*/
|
||||
private fun calculateRms(samples: ShortBuffer, numSamples: Int, channels: Int, maxFrames: Int): ByteArray {
|
||||
val numFrames: Int
|
||||
val frameStep: Float
|
||||
|
||||
val samplesPerChannel = numSamples / channels
|
||||
if (samplesPerChannel <= maxFrames) {
|
||||
frameStep = 1f
|
||||
numFrames = samplesPerChannel
|
||||
} else {
|
||||
frameStep = numSamples / maxFrames.toFloat()
|
||||
numFrames = maxFrames
|
||||
}
|
||||
|
||||
val rmsValues = FloatArray(numFrames)
|
||||
|
||||
var squaredFrameSum = 0.0
|
||||
var currentFrameIdx = 0
|
||||
|
||||
fun calculateFrameRms(nextFrameIdx: Int) {
|
||||
rmsValues[currentFrameIdx] = sqrt(squaredFrameSum.toFloat())
|
||||
|
||||
// Advance to the next frame.
|
||||
squaredFrameSum = 0.0
|
||||
currentFrameIdx = nextFrameIdx
|
||||
}
|
||||
|
||||
(0 until numSamples * channels step channels).forEach { sampleIdx ->
|
||||
val channelSampleIdx = sampleIdx / channels
|
||||
val frameIdx = (channelSampleIdx / frameStep).toInt()
|
||||
|
||||
if (currentFrameIdx != frameIdx) {
|
||||
// Calculate RMS value for the previous frame.
|
||||
calculateFrameRms(frameIdx)
|
||||
}
|
||||
|
||||
val samplesInCurrentFrame = ceil((currentFrameIdx + 1) * frameStep) - ceil(currentFrameIdx * frameStep)
|
||||
squaredFrameSum += (samples[sampleIdx] * samples[sampleIdx]) / samplesInCurrentFrame
|
||||
}
|
||||
// Calculate RMS value for the last frame.
|
||||
calculateFrameRms(-1)
|
||||
|
||||
// smoothArray(rmsValues, 1.0f)
|
||||
normalizeArray(rmsValues)
|
||||
|
||||
// Convert normalized result to a signed byte array.
|
||||
return rmsValues.map { value -> normalizedFloatToByte(value) }.toByteArray()
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes the array's values to [0..1] range.
|
||||
*/
|
||||
private fun normalizeArray(values: FloatArray) {
|
||||
var maxValue = -Float.MAX_VALUE
|
||||
var minValue = +Float.MAX_VALUE
|
||||
values.forEach { value ->
|
||||
if (value > maxValue) maxValue = value
|
||||
if (value < minValue) minValue = value
|
||||
}
|
||||
val span = maxValue - minValue
|
||||
|
||||
if (span == 0f) {
|
||||
values.indices.forEach { i -> values[i] = 0f }
|
||||
return
|
||||
}
|
||||
|
||||
values.indices.forEach { i -> values[i] = (values[i] - minValue) / span }
|
||||
}
|
||||
|
||||
private fun smoothArray(values: FloatArray, neighborWeight: Float = 1f): FloatArray {
|
||||
if (values.size < 3) return values
|
||||
|
||||
val result = FloatArray(values.size)
|
||||
result[0] = values[0]
|
||||
result[values.size - 1] == values[values.size - 1]
|
||||
for (i in 1 until values.size - 1) {
|
||||
result[i] = (values[i] + values[i - 1] * neighborWeight +
|
||||
values[i + 1] * neighborWeight) / (1f + neighborWeight * 2f)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Turns a signed byte into a [0..1] float. */
|
||||
inline fun byteToNormalizedFloat(value: Byte): Float {
|
||||
return (value + 128f) / 255f
|
||||
}
|
||||
|
||||
/** Turns a [0..1] float into a signed byte. */
|
||||
inline fun normalizedFloatToByte(value: Float): Byte {
|
||||
return (255f * value - 128f).roundToInt().toByte()
|
||||
}
|
@ -0,0 +1,336 @@
|
||||
package org.thoughtcrime.securesms.loki.views
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Color
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.drawable.AnimatedVectorDrawable
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.View.OnTouchListener
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import kotlinx.coroutines.*
|
||||
import network.loki.messenger.R
|
||||
import org.greenrobot.eventbus.EventBus
|
||||
import org.greenrobot.eventbus.Subscribe
|
||||
import org.greenrobot.eventbus.ThreadMode
|
||||
import org.thoughtcrime.securesms.ApplicationContext
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
|
||||
import org.thoughtcrime.securesms.audio.AudioSlidePlayer
|
||||
import org.thoughtcrime.securesms.components.AnimatingToggle
|
||||
import org.thoughtcrime.securesms.database.AttachmentDatabase
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory
|
||||
import org.thoughtcrime.securesms.events.PartProgressEvent
|
||||
import org.thoughtcrime.securesms.logging.Log
|
||||
import org.thoughtcrime.securesms.loki.api.PrepareAttachmentAudioExtrasJob
|
||||
import org.thoughtcrime.securesms.loki.utilities.getColorWithID
|
||||
import org.thoughtcrime.securesms.mms.AudioSlide
|
||||
import org.thoughtcrime.securesms.mms.SlideClickListener
|
||||
import java.io.IOException
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class MessageAudioView: FrameLayout, AudioSlidePlayer.Listener {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AudioViewKt"
|
||||
}
|
||||
|
||||
private val controlToggle: AnimatingToggle
|
||||
private val container: ViewGroup
|
||||
private val playButton: ImageView
|
||||
private val pauseButton: ImageView
|
||||
private val downloadButton: ImageView
|
||||
private val downloadProgress: ProgressBar
|
||||
private val seekBar: WaveformSeekBar
|
||||
private val totalDuration: TextView
|
||||
|
||||
private var downloadListener: SlideClickListener? = null
|
||||
private var audioSlidePlayer: AudioSlidePlayer? = null
|
||||
|
||||
/** Background coroutine scope that is available when the view is attached to a window. */
|
||||
private var asyncCoroutineScope: CoroutineScope? = null
|
||||
|
||||
private val loadingAnimation: SeekBarLoadingAnimation
|
||||
|
||||
constructor(context: Context): this(context, null)
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet?): this(context, attrs, 0)
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int): super(context, attrs, defStyleAttr) {
|
||||
View.inflate(context, R.layout.message_audio_view, this)
|
||||
container = findViewById(R.id.audio_widget_container)
|
||||
controlToggle = findViewById(R.id.control_toggle)
|
||||
playButton = findViewById(R.id.play)
|
||||
pauseButton = findViewById(R.id.pause)
|
||||
downloadButton = findViewById(R.id.download)
|
||||
downloadProgress = findViewById(R.id.download_progress)
|
||||
seekBar = findViewById(R.id.seek)
|
||||
totalDuration = findViewById(R.id.total_duration)
|
||||
|
||||
playButton.setOnClickListener {
|
||||
try {
|
||||
Log.d(TAG, "playbutton onClick")
|
||||
if (audioSlidePlayer != null) {
|
||||
togglePlayToPause()
|
||||
|
||||
// Restart the playback if progress bar is nearly at the end.
|
||||
val progress = if (seekBar.progress < 0.99f) seekBar.progress.toDouble() else 0.0
|
||||
|
||||
audioSlidePlayer!!.play(progress)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, e)
|
||||
}
|
||||
}
|
||||
pauseButton.setOnClickListener {
|
||||
Log.d(TAG, "pausebutton onClick")
|
||||
if (audioSlidePlayer != null) {
|
||||
togglePauseToPlay()
|
||||
audioSlidePlayer!!.stop()
|
||||
}
|
||||
}
|
||||
seekBar.isEnabled = false
|
||||
seekBar.progressChangeListener = object : WaveformSeekBar.ProgressChangeListener {
|
||||
override fun onProgressChanged(waveformSeekBar: WaveformSeekBar, progress: Float, fromUser: Boolean) {
|
||||
if (fromUser && audioSlidePlayer != null) {
|
||||
synchronized(audioSlidePlayer!!) {
|
||||
audioSlidePlayer!!.seekTo(progress.toDouble())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
playButton.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.play_icon))
|
||||
pauseButton.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.pause_icon))
|
||||
playButton.background = ContextCompat.getDrawable(context, R.drawable.ic_circle_fill_white_48dp)
|
||||
pauseButton.background = ContextCompat.getDrawable(context, R.drawable.ic_circle_fill_white_48dp)
|
||||
|
||||
if (attrs != null) {
|
||||
val typedArray = context.theme.obtainStyledAttributes(attrs, R.styleable.MessageAudioView, 0, 0)
|
||||
setTint(typedArray.getColor(R.styleable.MessageAudioView_foregroundTintColor, Color.WHITE),
|
||||
typedArray.getColor(R.styleable.MessageAudioView_waveformFillColor, Color.WHITE),
|
||||
typedArray.getColor(R.styleable.MessageAudioView_waveformBackgroundColor, Color.WHITE))
|
||||
container.setBackgroundColor(typedArray.getColor(R.styleable.MessageAudioView_widgetBackground, Color.TRANSPARENT))
|
||||
typedArray.recycle()
|
||||
}
|
||||
|
||||
loadingAnimation = SeekBarLoadingAnimation(this, seekBar)
|
||||
loadingAnimation.start()
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
if (!EventBus.getDefault().isRegistered(this)) EventBus.getDefault().register(this)
|
||||
|
||||
asyncCoroutineScope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow()
|
||||
EventBus.getDefault().unregister(this)
|
||||
|
||||
// Cancel all the background operations.
|
||||
asyncCoroutineScope!!.cancel()
|
||||
asyncCoroutineScope = null
|
||||
}
|
||||
|
||||
fun setAudio(audio: AudioSlide, showControls: Boolean) {
|
||||
when {
|
||||
showControls && audio.isPendingDownload -> {
|
||||
controlToggle.displayQuick(downloadButton)
|
||||
seekBar.isEnabled = false
|
||||
downloadButton.setOnClickListener { v -> downloadListener?.onClick(v, audio) }
|
||||
if (downloadProgress.isIndeterminate) {
|
||||
downloadProgress.isIndeterminate = false
|
||||
downloadProgress.progress = 0
|
||||
}
|
||||
}
|
||||
(showControls && audio.transferState == AttachmentDatabase.TRANSFER_PROGRESS_STARTED) -> {
|
||||
controlToggle.displayQuick(downloadProgress)
|
||||
seekBar.isEnabled = false
|
||||
downloadProgress.isIndeterminate = true
|
||||
}
|
||||
else -> {
|
||||
controlToggle.displayQuick(playButton)
|
||||
seekBar.isEnabled = true
|
||||
if (downloadProgress.isIndeterminate) {
|
||||
downloadProgress.isIndeterminate = false
|
||||
downloadProgress.progress = 100
|
||||
}
|
||||
|
||||
// Post to make sure it executes only when the view is attached to a window.
|
||||
post(::updateFromAttachmentAudioExtras)
|
||||
}
|
||||
}
|
||||
audioSlidePlayer = AudioSlidePlayer.createFor(context, audio, this)
|
||||
}
|
||||
|
||||
fun cleanup() {
|
||||
if (audioSlidePlayer != null && pauseButton.visibility == View.VISIBLE) {
|
||||
audioSlidePlayer!!.stop()
|
||||
}
|
||||
}
|
||||
|
||||
fun setDownloadClickListener(listener: SlideClickListener?) {
|
||||
downloadListener = listener
|
||||
}
|
||||
|
||||
fun setTint(@ColorInt foregroundTint: Int, @ColorInt waveformFill: Int, @ColorInt waveformBackground: Int) {
|
||||
playButton.backgroundTintList = ColorStateList.valueOf(resources.getColorWithID(R.color.white, context.theme))
|
||||
playButton.imageTintList = ColorStateList.valueOf(resources.getColorWithID(R.color.black, context.theme))
|
||||
pauseButton.backgroundTintList = ColorStateList.valueOf(resources.getColorWithID(R.color.white, context.theme))
|
||||
pauseButton.imageTintList = ColorStateList.valueOf(resources.getColorWithID(R.color.black, context.theme))
|
||||
|
||||
downloadButton.setColorFilter(foregroundTint, PorterDuff.Mode.SRC_IN)
|
||||
|
||||
downloadProgress.backgroundTintList = ColorStateList.valueOf(resources.getColorWithID(R.color.white, context.theme))
|
||||
downloadProgress.progressTintList = ColorStateList.valueOf(resources.getColorWithID(R.color.black, context.theme))
|
||||
downloadProgress.indeterminateTintList = ColorStateList.valueOf(resources.getColorWithID(R.color.black, context.theme))
|
||||
|
||||
totalDuration.setTextColor(foregroundTint)
|
||||
|
||||
seekBar.barProgressColor = waveformFill
|
||||
seekBar.barBackgroundColor = waveformBackground
|
||||
}
|
||||
|
||||
override fun onPlayerStart(player: AudioSlidePlayer) {
|
||||
if (pauseButton.visibility != View.VISIBLE) {
|
||||
togglePlayToPause()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayerStop(player: AudioSlidePlayer) {
|
||||
if (playButton.visibility != View.VISIBLE) {
|
||||
togglePauseToPlay()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayerProgress(player: AudioSlidePlayer, progress: Double, millis: Long) {
|
||||
seekBar.progress = progress.toFloat()
|
||||
}
|
||||
|
||||
override fun setFocusable(focusable: Boolean) {
|
||||
super.setFocusable(focusable)
|
||||
playButton.isFocusable = focusable
|
||||
pauseButton.isFocusable = focusable
|
||||
seekBar.isFocusable = focusable
|
||||
seekBar.isFocusableInTouchMode = focusable
|
||||
downloadButton.isFocusable = focusable
|
||||
}
|
||||
|
||||
override fun setClickable(clickable: Boolean) {
|
||||
super.setClickable(clickable)
|
||||
playButton.isClickable = clickable
|
||||
pauseButton.isClickable = clickable
|
||||
seekBar.isClickable = clickable
|
||||
seekBar.setOnTouchListener(if (clickable) null else
|
||||
OnTouchListener { _, _ -> return@OnTouchListener true }) // Suppress touch events.
|
||||
downloadButton.isClickable = clickable
|
||||
}
|
||||
|
||||
override fun setEnabled(enabled: Boolean) {
|
||||
super.setEnabled(enabled)
|
||||
playButton.isEnabled = enabled
|
||||
pauseButton.isEnabled = enabled
|
||||
downloadButton.isEnabled = enabled
|
||||
}
|
||||
|
||||
private fun togglePlayToPause() {
|
||||
controlToggle.displayQuick(pauseButton)
|
||||
val playToPauseDrawable = ContextCompat.getDrawable(context, R.drawable.play_to_pause_animation) as AnimatedVectorDrawable
|
||||
pauseButton.setImageDrawable(playToPauseDrawable)
|
||||
playToPauseDrawable.start()
|
||||
}
|
||||
|
||||
private fun togglePauseToPlay() {
|
||||
controlToggle.displayQuick(playButton)
|
||||
val pauseToPlayDrawable = ContextCompat.getDrawable(context, R.drawable.pause_to_play_animation) as AnimatedVectorDrawable
|
||||
playButton.setImageDrawable(pauseToPlayDrawable)
|
||||
pauseToPlayDrawable.start()
|
||||
}
|
||||
|
||||
private fun obtainDatabaseAttachment(): DatabaseAttachment? {
|
||||
audioSlidePlayer ?: return null
|
||||
val attachment = audioSlidePlayer!!.audioSlide.asAttachment()
|
||||
return if (attachment is DatabaseAttachment) attachment else null
|
||||
}
|
||||
|
||||
private fun updateFromAttachmentAudioExtras() {
|
||||
val attachment = obtainDatabaseAttachment() ?: return
|
||||
|
||||
val audioExtras = DatabaseFactory.getAttachmentDatabase(context)
|
||||
.getAttachmentAudioExtras(attachment.attachmentId)
|
||||
|
||||
// Schedule a job request if no audio extras were generated yet.
|
||||
if (audioExtras == null) {
|
||||
ApplicationContext.getInstance(context).jobManager
|
||||
.add(PrepareAttachmentAudioExtrasJob(attachment.attachmentId))
|
||||
return
|
||||
}
|
||||
|
||||
loadingAnimation.stop()
|
||||
seekBar.sampleData = audioExtras.visualSamples
|
||||
|
||||
if (audioExtras.durationMs > 0) {
|
||||
totalDuration.visibility = View.VISIBLE
|
||||
totalDuration.text = String.format("%02d:%02d",
|
||||
TimeUnit.MILLISECONDS.toMinutes(audioExtras.durationMs),
|
||||
TimeUnit.MILLISECONDS.toSeconds(audioExtras.durationMs))
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe(sticky = true, threadMode = ThreadMode.MAIN)
|
||||
fun onEvent(event: PartProgressEvent) {
|
||||
if (audioSlidePlayer != null && event.attachment == audioSlidePlayer!!.audioSlide.asAttachment()) {
|
||||
val progress = ((event.progress.toFloat() / event.total) * 100f).toInt()
|
||||
downloadProgress.progress = progress
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe(threadMode = ThreadMode.MAIN)
|
||||
fun onEvent(event: PrepareAttachmentAudioExtrasJob.AudioExtrasUpdatedEvent) {
|
||||
if (event.attachmentId == obtainDatabaseAttachment()?.attachmentId) {
|
||||
updateFromAttachmentAudioExtras()
|
||||
}
|
||||
}
|
||||
|
||||
private class SeekBarLoadingAnimation(
|
||||
private val hostView: View,
|
||||
private val seekBar: WaveformSeekBar): Runnable {
|
||||
|
||||
private var active = false
|
||||
|
||||
companion object {
|
||||
private const val UPDATE_PERIOD = 250L // In milliseconds.
|
||||
private val random = Random()
|
||||
}
|
||||
|
||||
fun start() {
|
||||
stop()
|
||||
active = true
|
||||
hostView.postDelayed(this, UPDATE_PERIOD)
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
active = false
|
||||
hostView.removeCallbacks(this)
|
||||
}
|
||||
|
||||
override fun run() {
|
||||
if (!active) return
|
||||
|
||||
// Generate a random samples with values up to the 50% of the maximum value.
|
||||
seekBar.sampleData = ByteArray(PrepareAttachmentAudioExtrasJob.VISUAL_RMS_FRAMES)
|
||||
{ (random.nextInt(127) - 64).toByte() }
|
||||
hostView.postDelayed(this, UPDATE_PERIOD)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,315 @@
|
||||
package org.thoughtcrime.securesms.loki.views
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import android.util.AttributeSet
|
||||
import android.util.TypedValue
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewConfiguration
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import androidx.core.math.MathUtils
|
||||
import network.loki.messenger.R
|
||||
import org.thoughtcrime.securesms.loki.utilities.audio.byteToNormalizedFloat
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class WaveformSeekBar : View {
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun dp(context: Context, dp: Float): Float {
|
||||
return TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_DIP,
|
||||
dp,
|
||||
context.resources.displayMetrics
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val sampleDataHolder = SampleDataHolder(::invalidate)
|
||||
/** An array of signed byte values representing the audio signal. */
|
||||
var sampleData: ByteArray?
|
||||
get() {
|
||||
return sampleDataHolder.getSamples()
|
||||
}
|
||||
set(value) {
|
||||
sampleDataHolder.setSamples(value)
|
||||
invalidate()
|
||||
}
|
||||
|
||||
/** Indicates whether the user is currently interacting with the view and performing a seeking gesture. */
|
||||
private var userSeeking = false
|
||||
private var _progress: Float = 0f
|
||||
/** In [0..1] range. */
|
||||
var progress: Float
|
||||
set(value) {
|
||||
// Do not let to modify the progress value from the outside
|
||||
// when the user is currently interacting with the view.
|
||||
if (userSeeking) return
|
||||
|
||||
_progress = value
|
||||
invalidate()
|
||||
progressChangeListener?.onProgressChanged(this, _progress, false)
|
||||
}
|
||||
get() {
|
||||
return _progress
|
||||
}
|
||||
|
||||
var barBackgroundColor: Int = Color.LTGRAY
|
||||
set(value) {
|
||||
field = value
|
||||
invalidate()
|
||||
}
|
||||
|
||||
var barProgressColor: Int = Color.WHITE
|
||||
set(value) {
|
||||
field = value
|
||||
invalidate()
|
||||
}
|
||||
|
||||
var barGap: Float = dp(context, 2f)
|
||||
set(value) {
|
||||
field = value
|
||||
invalidate()
|
||||
}
|
||||
|
||||
var barWidth: Float = dp(context, 5f)
|
||||
set(value) {
|
||||
field = value
|
||||
invalidate()
|
||||
}
|
||||
|
||||
var barMinHeight: Float = barWidth
|
||||
set(value) {
|
||||
field = value
|
||||
invalidate()
|
||||
}
|
||||
|
||||
var barCornerRadius: Float = dp(context, 2.5f)
|
||||
set(value) {
|
||||
field = value
|
||||
invalidate()
|
||||
}
|
||||
|
||||
var barGravity: WaveGravity = WaveGravity.CENTER
|
||||
set(value) {
|
||||
field = value
|
||||
invalidate()
|
||||
}
|
||||
|
||||
var progressChangeListener: ProgressChangeListener? = null
|
||||
|
||||
private val barPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val barRect = RectF()
|
||||
|
||||
private var canvasWidth = 0
|
||||
private var canvasHeight = 0
|
||||
|
||||
private var touchDownX = 0f
|
||||
private var touchDownProgress: Float = 0f
|
||||
private var scaledTouchSlop = ViewConfiguration.get(context).scaledTouchSlop
|
||||
|
||||
constructor(context: Context) : this(context, null)
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int)
|
||||
: super(context, attrs, defStyleAttr) {
|
||||
|
||||
val typedAttrs = context.obtainStyledAttributes(attrs, R.styleable.WaveformSeekBar)
|
||||
barWidth = typedAttrs.getDimension(R.styleable.WaveformSeekBar_bar_width, barWidth)
|
||||
barGap = typedAttrs.getDimension(R.styleable.WaveformSeekBar_bar_gap, barGap)
|
||||
barCornerRadius = typedAttrs.getDimension(
|
||||
R.styleable.WaveformSeekBar_bar_corner_radius,
|
||||
barCornerRadius)
|
||||
barMinHeight =
|
||||
typedAttrs.getDimension(R.styleable.WaveformSeekBar_bar_min_height, barMinHeight)
|
||||
barBackgroundColor = typedAttrs.getColor(
|
||||
R.styleable.WaveformSeekBar_bar_background_color,
|
||||
barBackgroundColor)
|
||||
barProgressColor =
|
||||
typedAttrs.getColor(R.styleable.WaveformSeekBar_bar_progress_color, barProgressColor)
|
||||
progress = typedAttrs.getFloat(R.styleable.WaveformSeekBar_progress, progress)
|
||||
barGravity = WaveGravity.fromString(
|
||||
typedAttrs.getString(R.styleable.WaveformSeekBar_bar_gravity))
|
||||
|
||||
typedAttrs.recycle()
|
||||
}
|
||||
|
||||
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||
super.onSizeChanged(w, h, oldw, oldh)
|
||||
canvasWidth = w
|
||||
canvasHeight = h
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
|
||||
val totalWidth = getAvailableWidth()
|
||||
val barAmount = (totalWidth / (barWidth + barGap)).toInt()
|
||||
|
||||
var lastBarRight = paddingLeft.toFloat()
|
||||
|
||||
(0 until barAmount).forEach { barIdx ->
|
||||
// Convert a signed byte to a [0..1] float.
|
||||
val barValue = byteToNormalizedFloat(sampleDataHolder.computeBarValue(barIdx, barAmount))
|
||||
|
||||
val barHeight = max(barMinHeight, getAvailableHeight() * barValue)
|
||||
|
||||
val top: Float = when (barGravity) {
|
||||
WaveGravity.TOP -> paddingTop.toFloat()
|
||||
WaveGravity.CENTER -> paddingTop + getAvailableHeight() * 0.5f - barHeight * 0.5f
|
||||
WaveGravity.BOTTOM -> canvasHeight - paddingBottom - barHeight
|
||||
}
|
||||
|
||||
barRect.set(lastBarRight, top, lastBarRight + barWidth, top + barHeight)
|
||||
|
||||
barPaint.color = if (barRect.right <= totalWidth * progress)
|
||||
barProgressColor else barBackgroundColor
|
||||
|
||||
canvas.drawRoundRect(barRect, barCornerRadius, barCornerRadius, barPaint)
|
||||
|
||||
lastBarRight = barRect.right + barGap
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
if (!isEnabled) return false
|
||||
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
userSeeking = true
|
||||
touchDownX = event.x
|
||||
touchDownProgress = progress
|
||||
updateProgress(event, false)
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
// Prevent any parent scrolling if the user scrolled more
|
||||
// than scaledTouchSlop on horizontal axis.
|
||||
if (abs(event.x - touchDownX) > scaledTouchSlop) {
|
||||
parent.requestDisallowInterceptTouchEvent(true)
|
||||
}
|
||||
updateProgress(event, false)
|
||||
}
|
||||
MotionEvent.ACTION_UP -> {
|
||||
userSeeking = false
|
||||
updateProgress(event, true)
|
||||
performClick()
|
||||
}
|
||||
MotionEvent.ACTION_CANCEL -> {
|
||||
updateProgress(touchDownProgress, false)
|
||||
userSeeking = false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun updateProgress(event: MotionEvent, notify: Boolean) {
|
||||
updateProgress(event.x / getAvailableWidth(), notify)
|
||||
}
|
||||
|
||||
private fun updateProgress(progress: Float, notify: Boolean) {
|
||||
_progress = MathUtils.clamp(progress, 0f, 1f)
|
||||
invalidate()
|
||||
|
||||
if (notify) {
|
||||
progressChangeListener?.onProgressChanged(this, _progress, true)
|
||||
}
|
||||
}
|
||||
|
||||
override fun performClick(): Boolean {
|
||||
super.performClick()
|
||||
return true
|
||||
}
|
||||
|
||||
private fun getAvailableWidth() = canvasWidth - paddingLeft - paddingRight
|
||||
private fun getAvailableHeight() = canvasHeight - paddingTop - paddingBottom
|
||||
|
||||
private class SampleDataHolder(private val invalidateDelegate: () -> Any) {
|
||||
|
||||
private var sampleDataFrom: ByteArray? = null
|
||||
private var sampleDataTo: ByteArray? = null
|
||||
private var progress = 1f // Mix between from and to values.
|
||||
|
||||
private var animation: ValueAnimator? = null
|
||||
|
||||
fun computeBarValue(barIdx: Int, barAmount: Int): Byte {
|
||||
/** @return The array's value at the interpolated index. */
|
||||
fun getSampleValue(sampleData: ByteArray?): Byte {
|
||||
if (sampleData == null || sampleData.isEmpty())
|
||||
return Byte.MIN_VALUE
|
||||
else {
|
||||
val sampleIdx = (barIdx * (sampleData.size / barAmount.toFloat())).toInt()
|
||||
return sampleData[sampleIdx]
|
||||
}
|
||||
}
|
||||
|
||||
if (progress == 1f) {
|
||||
return getSampleValue(sampleDataTo)
|
||||
}
|
||||
|
||||
val fromValue = getSampleValue(sampleDataFrom)
|
||||
val toValue = getSampleValue(sampleDataTo)
|
||||
val rawResultValue = fromValue * (1f - progress) + toValue * progress
|
||||
return rawResultValue.roundToInt().toByte()
|
||||
}
|
||||
|
||||
fun setSamples(sampleData: ByteArray?) {
|
||||
/** @return a mix between [sampleDataFrom] and [sampleDataTo] arrays according to the current [progress] value. */
|
||||
fun computeNewDataFromArray(): ByteArray? {
|
||||
if (sampleDataTo == null) return null
|
||||
if (sampleDataFrom == null) return sampleDataTo
|
||||
|
||||
val sampleSize = min(sampleDataFrom!!.size, sampleDataTo!!.size)
|
||||
return ByteArray(sampleSize) { i -> computeBarValue(i, sampleSize) }
|
||||
}
|
||||
|
||||
sampleDataFrom = computeNewDataFromArray()
|
||||
sampleDataTo = sampleData
|
||||
progress = 0f
|
||||
|
||||
animation?.cancel()
|
||||
animation = ValueAnimator.ofFloat(0f, 1f).apply {
|
||||
addUpdateListener { animation ->
|
||||
progress = animation.animatedValue as Float
|
||||
invalidateDelegate()
|
||||
}
|
||||
interpolator = DecelerateInterpolator(3f)
|
||||
duration = 500
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
fun getSamples(): ByteArray? {
|
||||
return sampleDataTo
|
||||
}
|
||||
}
|
||||
|
||||
enum class WaveGravity {
|
||||
TOP,
|
||||
CENTER,
|
||||
BOTTOM,
|
||||
;
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun fromString(gravity: String?): WaveGravity = when (gravity) {
|
||||
"1" -> TOP
|
||||
"2" -> CENTER
|
||||
else -> BOTTOM
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface ProgressChangeListener {
|
||||
fun onProgressChanged(waveformSeekBar: WaveformSeekBar, progress: Float, fromUser: Boolean)
|
||||
}
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
package org.thoughtcrime.securesms.util
|
||||
|
||||
import android.os.Parcel
|
||||
|
||||
import android.os.Parcelable
|
||||
|
||||
object ParcelableUtil {
|
||||
@JvmStatic
|
||||
fun marshall(parcelable: Parcelable): ByteArray {
|
||||
val parcel = Parcel.obtain()
|
||||
parcelable.writeToParcel(parcel, 0)
|
||||
val bytes = parcel.marshall()
|
||||
parcel.recycle()
|
||||
return bytes
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun unmarshall(bytes: ByteArray): Parcel {
|
||||
val parcel = Parcel.obtain()
|
||||
parcel.unmarshall(bytes, 0, bytes.size)
|
||||
parcel.setDataPosition(0) // This is extremely important!
|
||||
return parcel
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun <T> unmarshall(bytes: ByteArray, creator: Parcelable.Creator<T>): T {
|
||||
val parcel: Parcel = ParcelableUtil.unmarshall(bytes)
|
||||
val result = creator.createFromParcel(parcel)
|
||||
parcel.recycle()
|
||||
return result
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue