diff --git a/build.gradle b/build.gradle
index dba84195d7..208d6460fd 100644
--- a/build.gradle
+++ b/build.gradle
@@ -150,6 +150,7 @@ dependencies {
implementation "com.fasterxml.jackson.core:jackson-databind:2.9.8"
implementation "com.squareup.okhttp3:okhttp:3.12.1"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
+ implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9'
implementation "nl.komponents.kovenant:kovenant:$kovenant_version"
implementation "nl.komponents.kovenant:kovenant-android:$kovenant_version"
implementation "com.github.lelloman:android-identicons:v11"
@@ -195,7 +196,7 @@ def abiPostFix = ['armeabi-v7a' : 1,
android {
flavorDimensions "none"
compileSdkVersion 29
- buildToolsVersion '28.0.3'
+ buildToolsVersion '29.0.3'
useLibrary 'org.apache.http.legacy'
dexOptions {
diff --git a/res/drawable/circle_tintable_4dp_inset.xml b/res/drawable/circle_tintable_4dp_inset.xml
new file mode 100644
index 0000000000..92b0e0830c
--- /dev/null
+++ b/res/drawable/circle_tintable_4dp_inset.xml
@@ -0,0 +1,4 @@
+
+
\ No newline at end of file
diff --git a/res/layout/conversation_activity_attachment_editor_stub.xml b/res/layout/conversation_activity_attachment_editor_stub.xml
index 034400a61a..3ee19aac0b 100644
--- a/res/layout/conversation_activity_attachment_editor_stub.xml
+++ b/res/layout/conversation_activity_attachment_editor_stub.xml
@@ -32,7 +32,7 @@
app:minHeight="100dp"
app:maxHeight="300dp"/>
-
+ app:waveformFillColor="?conversation_item_audio_seek_bar_color_outgoing"
+ app:waveformBackgroundColor="?conversation_item_audio_seek_bar_background_color"/>
-
diff --git a/res/layout/conversation_item_sent_audio.xml b/res/layout/conversation_item_sent_audio.xml
index d298e5f424..cb7889fa1f 100644
--- a/res/layout/conversation_item_sent_audio.xml
+++ b/res/layout/conversation_item_sent_audio.xml
@@ -1,10 +1,11 @@
-
diff --git a/res/layout/audio_view.xml b/res/layout/message_audio_view.xml
similarity index 54%
rename from res/layout/audio_view.xml
rename to res/layout/message_audio_view.xml
index e5a33d9a41..0bb4abf2a5 100644
--- a/res/layout/audio_view.xml
+++ b/res/layout/message_audio_view.xml
@@ -2,7 +2,7 @@
+ tools:context="org.thoughtcrime.securesms.loki.views.MessageAudioView">
-
+ android:layout_gravity="center_vertical"
+ android:min="0"
+ android:max="100"
+ tools:visibility="gone"
+ tools:backgroundTint="@android:color/black"
+ tools:indeterminateTint="@android:color/white"/>
-
+
+
+
-
-
\ No newline at end of file
diff --git a/res/values-notnight-v21/themes.xml b/res/values-notnight-v21/themes.xml
index e8d5e80e71..2807ff2ed7 100644
--- a/res/values-notnight-v21/themes.xml
+++ b/res/values-notnight-v21/themes.xml
@@ -27,6 +27,9 @@
- @color/core_grey_60
- @drawable/ic_outline_info_24
+
+ - @color/accent
+ - @color/white
diff --git a/src/org/thoughtcrime/securesms/attachments/AttachmentId.java b/src/org/thoughtcrime/securesms/attachments/AttachmentId.java
index 3d43c84195..6f5160d75a 100644
--- a/src/org/thoughtcrime/securesms/attachments/AttachmentId.java
+++ b/src/org/thoughtcrime/securesms/attachments/AttachmentId.java
@@ -1,12 +1,15 @@
package org.thoughtcrime.securesms.attachments;
+import android.os.Parcel;
+import android.os.Parcelable;
+
import androidx.annotation.NonNull;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.thoughtcrime.securesms.util.Util;
-public class AttachmentId {
+public class AttachmentId implements Parcelable {
@JsonProperty
private final long rowId;
@@ -54,4 +57,33 @@ public class AttachmentId {
public int hashCode() {
return Util.hashCode(rowId, uniqueId);
}
+
+ //region Parcelable implementation.
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeLong(rowId);
+ dest.writeLong(uniqueId);
+ }
+
+ public static final Parcelable.Creator CREATOR =
+ new Parcelable.Creator() {
+
+ @Override
+ public AttachmentId createFromParcel(Parcel in) {
+ long rowId = in.readLong();
+ long uniqueId = in.readLong();
+ return new AttachmentId(rowId, uniqueId);
+ }
+
+ @Override
+ public AttachmentId[] newArray(int size) {
+ return new AttachmentId[size];
+ }
+ };
+ //endregion
}
diff --git a/src/org/thoughtcrime/securesms/attachments/DatabaseAttachmentAudioExtras.kt b/src/org/thoughtcrime/securesms/attachments/DatabaseAttachmentAudioExtras.kt
new file mode 100644
index 0000000000..f10a02272e
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/attachments/DatabaseAttachmentAudioExtras.kt
@@ -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()
+ }
+}
\ No newline at end of file
diff --git a/src/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java b/src/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java
index fa65d129e4..a737e3c264 100644
--- a/src/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java
+++ b/src/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java
@@ -32,6 +32,7 @@ import com.google.android.exoplayer2.source.MediaSource;
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector;
import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory;
+import org.jetbrains.annotations.NotNull;
import org.thoughtcrime.securesms.attachments.AttachmentServer;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.AudioSlide;
@@ -150,7 +151,11 @@ public class AudioSlidePlayer implements SensorEventListener {
case Player.STATE_ENDED:
Log.i(TAG, "onComplete");
+
+ long millis = mediaPlayer.getDuration();
+
synchronized (AudioSlidePlayer.this) {
+ mediaPlayer.release();
mediaPlayer = null;
if (audioAttachmentServer != null) {
@@ -167,6 +172,7 @@ public class AudioSlidePlayer implements SensorEventListener {
}
}
+ notifyOnProgress(1.0, millis);
notifyOnStop();
progressEventHandler.removeMessages(0);
}
@@ -233,6 +239,20 @@ public class AudioSlidePlayer implements SensorEventListener {
}
}
+ public synchronized boolean isReady() {
+ if (mediaPlayer == null) return false;
+
+ return mediaPlayer.getPlaybackState() == Player.STATE_READY && mediaPlayer.getPlayWhenReady();
+ }
+
+ public synchronized void seekTo(double progress) throws IOException {
+ if (mediaPlayer == null || !isReady()) {
+ play(progress);
+ } else {
+ mediaPlayer.seekTo((long) (mediaPlayer.getDuration() * progress));
+ }
+ }
+
public void setListener(@NonNull Listener listener) {
this.listener = new WeakReference<>(listener);
@@ -256,30 +276,15 @@ public class AudioSlidePlayer implements SensorEventListener {
}
private void notifyOnStart() {
- Util.runOnMain(new Runnable() {
- @Override
- public void run() {
- getListener().onStart();
- }
- });
+ Util.runOnMain(() -> getListener().onPlayerStart(AudioSlidePlayer.this));
}
private void notifyOnStop() {
- Util.runOnMain(new Runnable() {
- @Override
- public void run() {
- getListener().onStop();
- }
- });
+ Util.runOnMain(() -> getListener().onPlayerStop(AudioSlidePlayer.this));
}
private void notifyOnProgress(final double progress, final long millis) {
- Util.runOnMain(new Runnable() {
- @Override
- public void run() {
- getListener().onProgress(progress, millis);
- }
- });
+ Util.runOnMain(() -> getListener().onPlayerProgress(AudioSlidePlayer.this, progress, millis));
}
private @NonNull Listener getListener() {
@@ -288,11 +293,11 @@ public class AudioSlidePlayer implements SensorEventListener {
if (listener != null) return listener;
else return new Listener() {
@Override
- public void onStart() {}
+ public void onPlayerStart(@NotNull AudioSlidePlayer player) { }
@Override
- public void onStop() {}
+ public void onPlayerStop(@NotNull AudioSlidePlayer player) { }
@Override
- public void onProgress(double progress, long millis) {}
+ public void onPlayerProgress(@NotNull AudioSlidePlayer player, double progress, long millis) { }
};
}
@@ -355,9 +360,9 @@ public class AudioSlidePlayer implements SensorEventListener {
}
public interface Listener {
- void onStart();
- void onStop();
- void onProgress(double progress, long millis);
+ void onPlayerStart(@NonNull AudioSlidePlayer player);
+ void onPlayerStop(@NonNull AudioSlidePlayer player);
+ void onPlayerProgress(@NonNull AudioSlidePlayer player, double progress, long millis);
}
private static class ProgressEventHandler extends Handler {
diff --git a/src/org/thoughtcrime/securesms/components/AudioView.java b/src/org/thoughtcrime/securesms/components/AudioView.java
deleted file mode 100644
index 9e4c7c3e9a..0000000000
--- a/src/org/thoughtcrime/securesms/components/AudioView.java
+++ /dev/null
@@ -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);
- }
- }
-
-}
diff --git a/src/org/thoughtcrime/securesms/conversation/ConversationItem.java b/src/org/thoughtcrime/securesms/conversation/ConversationItem.java
index 6bb7970de7..e98ab75a43 100644
--- a/src/org/thoughtcrime/securesms/conversation/ConversationItem.java
+++ b/src/org/thoughtcrime/securesms/conversation/ConversationItem.java
@@ -60,7 +60,7 @@ import org.thoughtcrime.securesms.MediaPreviewActivity;
import org.thoughtcrime.securesms.MessageDetailsActivity;
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
import org.thoughtcrime.securesms.components.AlertView;
-import org.thoughtcrime.securesms.components.AudioView;
+import org.thoughtcrime.securesms.loki.views.MessageAudioView;
import org.thoughtcrime.securesms.components.ConversationItemFooter;
import org.thoughtcrime.securesms.components.ConversationItemThumbnail;
import org.thoughtcrime.securesms.components.DocumentView;
@@ -161,7 +161,7 @@ public class ConversationItem extends TapJackingProofLinearLayout
private @NonNull Set batchSelected = new HashSet<>();
private Recipient conversationRecipient;
private Stub mediaThumbnailStub;
- private Stub audioViewStub;
+ private Stub audioViewStub;
private Stub documentViewStub;
private Stub sharedContactStub;
private Stub linkPreviewStub;
diff --git a/src/org/thoughtcrime/securesms/database/AttachmentDatabase.java b/src/org/thoughtcrime/securesms/database/AttachmentDatabase.java
index 63bdd4ca70..2e124f1579 100644
--- a/src/org/thoughtcrime/securesms/database/AttachmentDatabase.java
+++ b/src/org/thoughtcrime/securesms/database/AttachmentDatabase.java
@@ -24,11 +24,12 @@ import android.graphics.Bitmap;
import android.media.MediaMetadataRetriever;
import android.net.Uri;
import android.os.Build;
+import android.text.TextUtils;
+import android.util.Pair;
+
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
-import android.text.TextUtils;
-import android.util.Pair;
import com.bumptech.glide.Glide;
@@ -39,6 +40,7 @@ import org.json.JSONException;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.attachments.AttachmentId;
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
+import org.thoughtcrime.securesms.attachments.DatabaseAttachmentAudioExtras;
import org.thoughtcrime.securesms.crypto.AttachmentSecret;
import org.thoughtcrime.securesms.crypto.ClassicDecryptingPartInputStream;
import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream;
@@ -51,10 +53,10 @@ import org.thoughtcrime.securesms.mms.PartAuthority;
import org.thoughtcrime.securesms.stickers.StickerLocator;
import org.thoughtcrime.securesms.util.BitmapDecodingException;
import org.thoughtcrime.securesms.util.BitmapUtil;
+import org.thoughtcrime.securesms.util.ExternalStorageUtil;
import org.thoughtcrime.securesms.util.JsonUtils;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.MediaUtil.ThumbnailData;
-import org.thoughtcrime.securesms.util.ExternalStorageUtil;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.video.EncryptedMediaDataSource;
@@ -72,6 +74,8 @@ import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
+import kotlin.jvm.Synchronized;
+
public class AttachmentDatabase extends Database {
private static final String TAG = AttachmentDatabase.class.getSimpleName();
@@ -105,6 +109,9 @@ public class AttachmentDatabase extends Database {
static final String CAPTION = "caption";
public static final String URL = "url";
public static final String DIRECTORY = "parts";
+ // "audio/*" mime type only related columns.
+ static final String AUDIO_VISUAL_SAMPLES = "audio_visual_samples"; // Small amount of audio byte samples to visualise the content (e.g. draw waveform).
+ static final String AUDIO_DURATION = "audio_duration"; // Duration of the audio track in milliseconds.
public static final int TRANSFER_PROGRESS_DONE = 0;
public static final int TRANSFER_PROGRESS_STARTED = 1;
@@ -112,6 +119,7 @@ public class AttachmentDatabase extends Database {
public static final int TRANSFER_PROGRESS_FAILED = 3;
private static final String PART_ID_WHERE = ROW_ID + " = ? AND " + UNIQUE_ID + " = ?";
+ private static final String PART_AUDIO_ONLY_WHERE = CONTENT_TYPE + " LIKE \"audio/%\"";
private static final String[] PROJECTION = new String[] {ROW_ID,
MMS_ID, CONTENT_TYPE, NAME, CONTENT_DISPOSITION,
@@ -121,6 +129,8 @@ public class AttachmentDatabase extends Database {
QUOTE, DATA_RANDOM, THUMBNAIL_RANDOM, WIDTH, HEIGHT,
CAPTION, STICKER_PACK_ID, STICKER_PACK_KEY, STICKER_ID, URL};
+ private static final String[] PROJECTION_AUDIO_EXTRAS = new String[] {AUDIO_VISUAL_SAMPLES, AUDIO_DURATION};
+
public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ROW_ID + " INTEGER PRIMARY KEY, " +
MMS_ID + " INTEGER, " + "seq" + " INTEGER DEFAULT 0, " +
CONTENT_TYPE + " TEXT, " + NAME + " TEXT, " + "chset" + " INTEGER, " +
@@ -133,7 +143,8 @@ public class AttachmentDatabase extends Database {
VOICE_NOTE + " INTEGER DEFAULT 0, " + DATA_RANDOM + " BLOB, " + THUMBNAIL_RANDOM + " BLOB, " +
QUOTE + " INTEGER DEFAULT 0, " + WIDTH + " INTEGER DEFAULT 0, " + HEIGHT + " INTEGER DEFAULT 0, " +
CAPTION + " TEXT DEFAULT NULL, " + URL + " TEXT, " + STICKER_PACK_ID + " TEXT DEFAULT NULL, " +
- STICKER_PACK_KEY + " DEFAULT NULL, " + STICKER_ID + " INTEGER DEFAULT -1);";
+ STICKER_PACK_KEY + " DEFAULT NULL, " + STICKER_ID + " INTEGER DEFAULT -1," +
+ AUDIO_VISUAL_SAMPLES + " BLOB, " + AUDIO_DURATION + " INTEGER);";
public static final String[] CREATE_INDEXS = {
"CREATE INDEX IF NOT EXISTS part_mms_id_index ON " + TABLE_NAME + " (" + MMS_ID + ");",
@@ -822,6 +833,49 @@ public class AttachmentDatabase extends Database {
}
}
+ /**
+ * Retrieves the audio extra values associated with the attachment. Only "audio/*" mime type attachments are accepted.
+ * @return the related audio extras or null in case any of the audio extra columns are empty or the attachment is not an audio.
+ */
+ @Synchronized
+ public @Nullable DatabaseAttachmentAudioExtras getAttachmentAudioExtras(@NonNull AttachmentId attachmentId) {
+ try (Cursor cursor = databaseHelper.getReadableDatabase()
+ // We expect all the audio extra values to be present (not null) or reject the whole record.
+ .query(TABLE_NAME,
+ PROJECTION_AUDIO_EXTRAS,
+ PART_ID_WHERE +
+ " AND " + AUDIO_VISUAL_SAMPLES + " IS NOT NULL" +
+ " AND " + AUDIO_DURATION + " IS NOT NULL" +
+ " AND " + PART_AUDIO_ONLY_WHERE,
+ attachmentId.toStrings(),
+ null, null, null, "1")) {
+
+ if (cursor == null || !cursor.moveToFirst()) return null;
+
+ byte[] audioSamples = cursor.getBlob(cursor.getColumnIndexOrThrow(AUDIO_VISUAL_SAMPLES));
+ long duration = cursor.getLong(cursor.getColumnIndexOrThrow(AUDIO_DURATION));
+
+ return new DatabaseAttachmentAudioExtras(attachmentId, audioSamples, duration);
+ }
+ }
+
+ /**
+ * Updates audio extra columns for the "audio/*" mime type attachments only.
+ * @return true if the update operation was successful.
+ */
+ @Synchronized
+ public boolean setAttachmentAudioExtras(@NonNull DatabaseAttachmentAudioExtras extras) {
+ ContentValues values = new ContentValues();
+ values.put(AUDIO_VISUAL_SAMPLES, extras.getVisualSamples());
+ values.put(AUDIO_DURATION, extras.getDurationMs());
+
+ int alteredRows = databaseHelper.getWritableDatabase().update(TABLE_NAME,
+ values,
+ PART_ID_WHERE + " AND " + PART_AUDIO_ONLY_WHERE,
+ extras.getAttachmentId().toStrings());
+
+ return alteredRows > 0;
+ }
@VisibleForTesting
class ThumbnailFetchCallable implements Callable {
diff --git a/src/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/src/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java
index d57c258717..804bc99d8b 100644
--- a/src/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java
+++ b/src/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java
@@ -93,9 +93,10 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
private static final int lokiV14_BACKUP_FILES = 35;
private static final int lokiV15 = 36;
private static final int lokiV16 = 37;
- private static final int lokiV17_CLEAR_BG_POLL_JOBS = 38;
+ private static final int lokiV17 = 38;
+ private static final int lokiV18_CLEAR_BG_POLL_JOBS = 39;
- private static final int DATABASE_VERSION = lokiV17_CLEAR_BG_POLL_JOBS; // Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes
+ private static final int DATABASE_VERSION = lokiV18_CLEAR_BG_POLL_JOBS; // Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes
private static final String DATABASE_NAME = "signal.db";
private final Context context;
@@ -639,7 +640,12 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
db.execSQL(LokiAPIDatabase.getCreateOpenGroupProfilePictureTableCommand());
}
- if (oldVersion < lokiV17_CLEAR_BG_POLL_JOBS) {
+ if (oldVersion < lokiV17) {
+ db.execSQL("ALTER TABLE part ADD COLUMN audio_visual_samples BLOB");
+ db.execSQL("ALTER TABLE part ADD COLUMN audio_duration INTEGER");
+ }
+
+ if (oldVersion < lokiV18_CLEAR_BG_POLL_JOBS) {
// BackgroundPollJob was replaced with BackgroundPollWorker. Clear all the scheduled job records.
db.execSQL("DELETE FROM job_spec WHERE factory_key = 'BackgroundPollJob'");
db.execSQL("DELETE FROM constraint_spec WHERE factory_key = 'BackgroundPollJob'");
diff --git a/src/org/thoughtcrime/securesms/jobmanager/Data.java b/src/org/thoughtcrime/securesms/jobmanager/Data.java
index 72e8508a5b..eff9fa1478 100644
--- a/src/org/thoughtcrime/securesms/jobmanager/Data.java
+++ b/src/org/thoughtcrime/securesms/jobmanager/Data.java
@@ -1,13 +1,19 @@
package org.thoughtcrime.securesms.jobmanager;
+import android.os.Parcelable;
+
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.fasterxml.jackson.annotation.JsonProperty;
+import org.thoughtcrime.securesms.util.ParcelableUtil;
+
import java.util.HashMap;
import java.util.Map;
+// TODO AC: For now parcelable objects utilize byteArrays field to store their data into.
+// Introduce a dedicated Map field specifically for parcelable needs.
public class Data {
public static final Data EMPTY = new Data.Builder().build();
@@ -213,6 +219,16 @@ public class Data {
return byteArrays.get(key);
}
+ public boolean hasParcelable(@NonNull String key) {
+ return byteArrays.containsKey(key);
+ }
+
+ public T getParcelable(@NonNull String key, @NonNull Parcelable.Creator creator) {
+ throwIfAbsent(byteArrays, key);
+ byte[] bytes = byteArrays.get(key);
+ return ParcelableUtil.unmarshall(bytes, creator);
+ }
+
private void throwIfAbsent(@NonNull Map map, @NonNull String key) {
if (!map.containsKey(key)) {
throw new IllegalStateException("Tried to retrieve a value with key '" + key + "', but it wasn't present.");
@@ -301,6 +317,12 @@ public class Data {
return this;
}
+ public Builder putParcelable(@NonNull String key, @NonNull Parcelable value) {
+ byte[] bytes = ParcelableUtil.marshall(value);
+ byteArrays.put(key, bytes);
+ return this;
+ }
+
public Data build() {
return new Data(strings,
stringArrays,
diff --git a/src/org/thoughtcrime/securesms/jobmanager/migration/WorkManagerFactoryMappings.java b/src/org/thoughtcrime/securesms/jobmanager/migration/WorkManagerFactoryMappings.java
index 5f82cae0c6..0ef55f60fc 100644
--- a/src/org/thoughtcrime/securesms/jobmanager/migration/WorkManagerFactoryMappings.java
+++ b/src/org/thoughtcrime/securesms/jobmanager/migration/WorkManagerFactoryMappings.java
@@ -44,6 +44,7 @@ import org.thoughtcrime.securesms.jobs.SmsSentJob;
import org.thoughtcrime.securesms.jobs.TrimThreadJob;
import org.thoughtcrime.securesms.jobs.TypingSendJob;
import org.thoughtcrime.securesms.jobs.UpdateApkJob;
+import org.thoughtcrime.securesms.loki.api.PrepareAttachmentAudioExtrasJob;
import org.thoughtcrime.securesms.loki.protocol.ClosedGroupUpdateMessageSendJob;
import org.thoughtcrime.securesms.loki.protocol.NullMessageSendJob;
import org.thoughtcrime.securesms.loki.protocol.SessionRequestMessageSendJob;
@@ -100,6 +101,7 @@ public class WorkManagerFactoryMappings {
put(TrimThreadJob.class.getName(), TrimThreadJob.KEY);
put(TypingSendJob.class.getName(), TypingSendJob.KEY);
put(UpdateApkJob.class.getName(), UpdateApkJob.KEY);
+ put(PrepareAttachmentAudioExtrasJob.class.getName(), PrepareAttachmentAudioExtrasJob.KEY);
}};
public static @Nullable String getFactoryKey(@NonNull String workManagerClass) {
diff --git a/src/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/src/org/thoughtcrime/securesms/jobs/JobManagerFactories.java
index 111f7968e7..82dbf831f7 100644
--- a/src/org/thoughtcrime/securesms/jobs/JobManagerFactories.java
+++ b/src/org/thoughtcrime/securesms/jobs/JobManagerFactories.java
@@ -14,6 +14,7 @@ import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraintObserver;
import org.thoughtcrime.securesms.jobmanager.impl.NetworkOrCellServiceConstraint;
import org.thoughtcrime.securesms.jobmanager.impl.SqlCipherMigrationConstraint;
import org.thoughtcrime.securesms.jobmanager.impl.SqlCipherMigrationConstraintObserver;
+import org.thoughtcrime.securesms.loki.api.PrepareAttachmentAudioExtrasJob;
import org.thoughtcrime.securesms.loki.protocol.ClosedGroupUpdateMessageSendJob;
import org.thoughtcrime.securesms.loki.protocol.NullMessageSendJob;
import org.thoughtcrime.securesms.loki.protocol.SessionRequestMessageSendJob;
@@ -77,6 +78,7 @@ public final class JobManagerFactories {
put(TrimThreadJob.KEY, new TrimThreadJob.Factory());
put(TypingSendJob.KEY, new TypingSendJob.Factory());
put(UpdateApkJob.KEY, new UpdateApkJob.Factory());
+ put(PrepareAttachmentAudioExtrasJob.KEY, new PrepareAttachmentAudioExtrasJob.Factory());
}};
}
diff --git a/src/org/thoughtcrime/securesms/loki/activities/HomeActivity.kt b/src/org/thoughtcrime/securesms/loki/activities/HomeActivity.kt
index 5db0535a33..8a5cb4ae5c 100644
--- a/src/org/thoughtcrime/securesms/loki/activities/HomeActivity.kt
+++ b/src/org/thoughtcrime/securesms/loki/activities/HomeActivity.kt
@@ -26,11 +26,13 @@ import kotlinx.android.synthetic.main.activity_home.*
import network.loki.messenger.R
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
+import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.conversation.ConversationActivity
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.jobs.MultiDeviceBlockedUpdateJob
+import org.thoughtcrime.securesms.loki.api.PrepareAttachmentAudioExtrasJob
import org.thoughtcrime.securesms.loki.dialogs.ConversationOptionsBottomSheet
import org.thoughtcrime.securesms.loki.dialogs.LightThemeFeatureIntroBottomSheet
import org.thoughtcrime.securesms.loki.dialogs.MultiDeviceRemovalBottomSheet
diff --git a/src/org/thoughtcrime/securesms/loki/api/PrepareAttachmentAudioExtrasJob.kt b/src/org/thoughtcrime/securesms/loki/api/PrepareAttachmentAudioExtrasJob.kt
new file mode 100644
index 0000000000..07da933adf
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/loki/api/PrepareAttachmentAudioExtrasJob.kt
@@ -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 {
+ 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.
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/org/thoughtcrime/securesms/loki/utilities/audio/DecodedAudio.kt b/src/org/thoughtcrime/securesms/loki/utilities/audio/DecodedAudio.kt
new file mode 100644
index 0000000000..12c965428b
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/loki/utilities/audio/DecodedAudio.kt
@@ -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 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()
+}
\ No newline at end of file
diff --git a/src/org/thoughtcrime/securesms/loki/views/MessageAudioView.kt b/src/org/thoughtcrime/securesms/loki/views/MessageAudioView.kt
new file mode 100644
index 0000000000..3cccb6f4b8
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/loki/views/MessageAudioView.kt
@@ -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)
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/org/thoughtcrime/securesms/loki/views/WaveformSeekBar.kt b/src/org/thoughtcrime/securesms/loki/views/WaveformSeekBar.kt
new file mode 100644
index 0000000000..56ddb0f3c5
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/loki/views/WaveformSeekBar.kt
@@ -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)
+ }
+}
\ No newline at end of file
diff --git a/src/org/thoughtcrime/securesms/mms/AttachmentManager.java b/src/org/thoughtcrime/securesms/mms/AttachmentManager.java
index 80af560c00..a4fac15f14 100644
--- a/src/org/thoughtcrime/securesms/mms/AttachmentManager.java
+++ b/src/org/thoughtcrime/securesms/mms/AttachmentManager.java
@@ -41,7 +41,7 @@ import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.MediaPreviewActivity;
import org.thoughtcrime.securesms.TransportOption;
import org.thoughtcrime.securesms.attachments.Attachment;
-import org.thoughtcrime.securesms.components.AudioView;
+import org.thoughtcrime.securesms.loki.views.MessageAudioView;
import org.thoughtcrime.securesms.components.DocumentView;
import org.thoughtcrime.securesms.components.RemovableEditableMediaView;
import org.thoughtcrime.securesms.components.ThumbnailView;
@@ -91,7 +91,7 @@ public class AttachmentManager {
private RemovableEditableMediaView removableMediaView;
private ThumbnailView thumbnail;
- private AudioView audioView;
+ private MessageAudioView audioView;
private DocumentView documentView;
private SignalMapView mapView;
diff --git a/src/org/thoughtcrime/securesms/mms/Slide.java b/src/org/thoughtcrime/securesms/mms/Slide.java
index cdf5ce5456..19c00664af 100644
--- a/src/org/thoughtcrime/securesms/mms/Slide.java
+++ b/src/org/thoughtcrime/securesms/mms/Slide.java
@@ -131,7 +131,7 @@ public abstract class Slide {
public @NonNull String getContentDescription() { return ""; }
- public Attachment asAttachment() {
+ public @NonNull Attachment asAttachment() {
return attachment;
}
diff --git a/src/org/thoughtcrime/securesms/util/ParcelableUtil.kt b/src/org/thoughtcrime/securesms/util/ParcelableUtil.kt
new file mode 100644
index 0000000000..2756500b22
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/util/ParcelableUtil.kt
@@ -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 unmarshall(bytes: ByteArray, creator: Parcelable.Creator): T {
+ val parcel: Parcel = ParcelableUtil.unmarshall(bytes)
+ val result = creator.createFromParcel(parcel)
+ parcel.recycle()
+ return result
+ }
+}
\ No newline at end of file