diff --git a/build.gradle b/build.gradle index dba84195d..208d6460f 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 000000000..92b0e0830 --- /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 034400a61..3ee19aac0 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 d298e5f42..cb7889fa1 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 e5a33d9a4..0bb4abf2a 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 e8d5e80e7..2807ff2ed 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 3d43c8419..6f5160d75 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 000000000..f10a02272 --- /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 fa65d129e..a737e3c26 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 9e4c7c3e9..000000000 --- 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 6bb7970de..e98ab75a4 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 63bdd4ca7..2e124f157 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 d57c25871..804bc99d8 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 72e8508a5..eff9fa147 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 5f82cae0c..0ef55f60f 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 111f7968e..82dbf831f 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 5db0535a3..8a5cb4ae5 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 000000000..07da933ad --- /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 000000000..12c965428 --- /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 000000000..3cccb6f4b --- /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 000000000..56ddb0f3c --- /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 80af560c0..a4fac15f1 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 cdf5ce545..19c00664a 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 000000000..2756500b2 --- /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