Merge branch 'dev' of https://github.com/loki-project/session-android into background-polling

This commit is contained in:
Anton Chekulaev 2020-10-29 11:22:31 +11:00
commit 730b6fa343
27 changed files with 1494 additions and 411 deletions

View file

@ -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 {

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/circle_tintable"
android:inset="4dp"/>

View file

@ -32,7 +32,7 @@
app:minHeight="100dp"
app:maxHeight="300dp"/>
<org.thoughtcrime.securesms.components.AudioView
<org.thoughtcrime.securesms.loki.views.MessageAudioView
android:id="@+id/attachment_audio"
android:layout_width="210dp"
android:layout_height="wrap_content"
@ -41,7 +41,8 @@
android:paddingBottom="15dp"
app:widgetBackground="?conversation_item_bubble_background"
app:foregroundTintColor="?android:colorControlNormal"
app:backgroundTintColor="?conversation_item_bubble_background"/>
app:waveformFillColor="?conversation_item_audio_seek_bar_color_outgoing"
app:waveformBackgroundColor="?conversation_item_audio_seek_bar_background_color"/>
<org.thoughtcrime.securesms.components.DocumentView
android:id="@+id/attachment_document"

View file

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<org.thoughtcrime.securesms.components.AudioView
<org.thoughtcrime.securesms.loki.views.MessageAudioView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
@ -8,5 +8,6 @@
android:layout_height="wrap_content"
android:visibility="gone"
app:foregroundTintColor="?android:colorControlNormal"
app:backgroundTintColor="?message_received_background_color"
app:waveformFillColor="?conversation_item_audio_seek_bar_color_incoming"
app:waveformBackgroundColor="?conversation_item_audio_seek_bar_background_color"
tools:visibility="visible"/>

View file

@ -1,10 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<org.thoughtcrime.securesms.components.AudioView
<org.thoughtcrime.securesms.loki.views.MessageAudioView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/audio_view"
android:layout_width="210dp"
android:layout_height="wrap_content"
app:foregroundTintColor="?android:colorControlNormal"
app:backgroundTintColor="?message_sent_background_color"
app:waveformFillColor="?conversation_item_audio_seek_bar_color_outgoing"
app:waveformBackgroundColor="?conversation_item_audio_seek_bar_background_color"
android:visibility="gone"/>

View file

@ -2,7 +2,7 @@
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:context="org.thoughtcrime.securesms.components.AudioView">
tools:context="org.thoughtcrime.securesms.loki.views.MessageAudioView">
<LinearLayout android:id="@+id/audio_widget_container"
android:orientation="vertical"
@ -15,25 +15,28 @@
<org.thoughtcrime.securesms.components.AnimatingToggle
android:id="@+id/control_toggle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="center"
android:gravity="center">
<com.pnikosis.materialishprogress.ProgressWheel
<ProgressBar
android:id="@+id/download_progress"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="8dp"
android:background="@drawable/circle_tintable_4dp_inset"
android:visibility="gone"
android:layout_gravity="center"
app:matProg_barColor="@color/white"
app:matProg_linearProgress="true"
app:matProg_spinSpeed="0.333"
tools:visibility="gone"/>
android:layout_gravity="center_vertical"
android:min="0"
android:max="100"
tools:visibility="gone"
tools:backgroundTint="@android:color/black"
tools:indeterminateTint="@android:color/white"/>
<ImageView android:id="@+id/play"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center_vertical"
android:gravity="center_vertical"
android:clickable="true"
@ -45,8 +48,8 @@
tools:visibility="visible"/>
<ImageView android:id="@+id/pause"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center_vertical"
android:gravity="center_vertical"
android:clickable="true"
@ -58,8 +61,8 @@
tools:visibility="gone"/>
<ImageView android:id="@+id/download"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center_vertical"
android:clickable="true"
android:visibility="gone"
@ -70,27 +73,36 @@
</org.thoughtcrime.securesms.components.AnimatingToggle>
<SeekBar android:id="@+id/seek"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"/>
<org.thoughtcrime.securesms.loki.views.WaveformSeekBar
android:id="@+id/seek"
android:layout_width="0dp"
android:layout_height="38dp"
android:layout_weight="1"
android:layout_gravity="center_vertical"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp"
app:bar_gravity="center"
app:bar_width="4dp"
app:bar_corner_radius="2dp"
app:bar_gap="1dp"
tools:progress="0.5"
tools:bar_background_color="#bbb"
tools:bar_progress_color="?colorPrimary"/>
<TextView android:id="@+id/total_duration"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textSize="@dimen/conversation_item_date_text_size"
android:fontFamily="sans-serif-light"
android:autoLink="none"
android:visibility="gone"
tools:text="0:05"
tools:visibility="visible"/>
</LinearLayout>
<TextView android:id="@+id/timestamp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="76dip"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textColor="?conversation_item_sent_text_secondary_color"
android:textSize="@dimen/conversation_item_date_text_size"
android:fontFamily="sans-serif-light"
android:autoLink="none"
android:visibility="gone"
tools:text="00:15"
tools:visibility="visible"
/>
</LinearLayout>
</merge>

View file

@ -27,6 +27,9 @@
<item name="media_keyboard_button_color">@color/core_grey_60</item>
<item name="menu_info_icon">@drawable/ic_outline_info_24</item>
<item name="conversation_item_audio_seek_bar_color_incoming">@color/accent</item>
<item name="conversation_item_audio_seek_bar_color_outgoing">@color/white</item>
</style>
<style name="Theme.Session.DayNight" parent="Theme.Session.Light">

View file

@ -84,6 +84,9 @@
<attr name="conversation_item_sticky_date_background" format="reference" />
<attr name="conversation_item_sticky_date_text_color" format="color" />
<attr name="conversation_item_image_outline_color" format="color" />
<attr name="conversation_item_audio_seek_bar_color_incoming" format="reference|color" />
<attr name="conversation_item_audio_seek_bar_color_outgoing" format="reference|color" />
<attr name="conversation_item_audio_seek_bar_background_color" format="reference|color" />
<attr name="dialog_info_icon" format="reference" />
<attr name="dialog_alert_icon" format="reference" />
@ -169,10 +172,11 @@
<attr name="useSmallIcon" format="boolean" />
</declare-styleable>
<declare-styleable name="AudioView">
<declare-styleable name="MessageAudioView">
<attr name="widgetBackground" format="color"/>
<attr name="foregroundTintColor" format="color" />
<attr name="backgroundTintColor" format="color" />
<attr name="waveformFillColor" format="reference|color" />
<attr name="waveformBackgroundColor" format="reference|color" />
</declare-styleable>
<declare-styleable name="CircleColorImageView">
@ -287,4 +291,20 @@
<attr name="labeledEditText_background" format="color" />
<attr name="labeledEditText_textLayout" format="reference" />
</declare-styleable>
<declare-styleable name="WaveformSeekBar">
<attr name="progress" format="float"/>
<attr name="bar_width" format="dimension"/>
<attr name="bar_gap" format="dimension"/>
<attr name="bar_min_height" format="dimension"/>
<attr name="bar_corner_radius" format="dimension"/>
<attr name="bar_background_color" format="color"/>
<attr name="bar_progress_color" format="color"/>
<!-- Corresponds to WaveformSeekBar.WaveGravity enum. -->
<attr name="bar_gravity" format="enum">
<enum name="top" value="1" />
<enum name="center" value="2" />
<enum name="bottom" value="3" />
</attr>
</declare-styleable>
</resources>

View file

@ -87,6 +87,10 @@
<item name="quick_camera_icon">@drawable/ic_baseline_photo_camera_24</item>
<item name="quick_mic_icon">@drawable/ic_baseline_mic_24</item>
<item name="conversation_item_audio_seek_bar_color_incoming">@color/accent</item>
<item name="conversation_item_audio_seek_bar_color_outgoing">@color/accent</item>
<item name="conversation_item_audio_seek_bar_background_color">@color/text</item>
</style>
<!-- This should be the default theme for the application. -->

View file

@ -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<AttachmentId> CREATOR =
new Parcelable.Creator<AttachmentId>() {
@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
}

View file

@ -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()
}
}

View file

@ -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 {

View file

@ -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);
}
}
}

View file

@ -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<MessageRecord> batchSelected = new HashSet<>();
private Recipient conversationRecipient;
private Stub<ConversationItemThumbnail> mediaThumbnailStub;
private Stub<AudioView> audioViewStub;
private Stub<MessageAudioView> audioViewStub;
private Stub<DocumentView> documentViewStub;
private Stub<SharedContactView> sharedContactStub;
private Stub<LinkPreviewView> linkPreviewStub;

View file

@ -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<InputStream> {

View file

@ -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'");

View file

@ -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<String, byte[]> 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 extends Parcelable> T getParcelable(@NonNull String key, @NonNull Parcelable.Creator<T> 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,

View file

@ -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) {

View file

@ -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());
}};
}

View file

@ -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

View file

@ -0,0 +1,167 @@
package org.thoughtcrime.securesms.loki.api
import android.media.MediaDataSource
import android.os.Build
import android.util.Log
import androidx.annotation.RequiresApi
import org.greenrobot.eventbus.EventBus
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.DatabaseAttachmentAudioExtras
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.jobmanager.Data
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobs.BaseJob
import org.thoughtcrime.securesms.loki.utilities.audio.DecodedAudio
import org.thoughtcrime.securesms.mms.PartAuthority
import java.io.InputStream
import java.lang.IllegalStateException
import java.util.*
import java.util.concurrent.TimeUnit
/**
* Decodes the audio content of the related attachment entry
* and caches the result with [DatabaseAttachmentAudioExtras] data.
*
* It only process attachments with "audio" mime types.
*
* Due to [DecodedAudio] implementation limitations, it only works for API 23+.
* For any lower targets fake data will be generated.
*
* You can subscribe to [AudioExtrasUpdatedEvent] to be notified about the successful result.
*/
//TODO AC: Rewrite to WorkManager API when
// https://github.com/loki-project/session-android/pull/354 is merged.
class PrepareAttachmentAudioExtrasJob : BaseJob {
companion object {
private const val TAG = "AttachAudioExtrasJob"
const val KEY = "PrepareAttachmentAudioExtrasJob"
const val DATA_ATTACH_ID = "attachment_id"
const val VISUAL_RMS_FRAMES = 32 // The amount of values to be computed for the visualization.
}
private val attachmentId: AttachmentId
constructor(attachmentId: AttachmentId) : this(Parameters.Builder()
.setQueue(KEY)
.setLifespan(TimeUnit.DAYS.toMillis(1))
.build(),
attachmentId)
private constructor(parameters: Parameters, attachmentId: AttachmentId) : super(parameters) {
this.attachmentId = attachmentId
}
override fun serialize(): Data {
return Data.Builder().putParcelable(DATA_ATTACH_ID, attachmentId).build();
}
override fun getFactoryKey(): String { return KEY
}
override fun onShouldRetry(e: Exception): Boolean {
return false
}
override fun onCanceled() { }
override fun onRun() {
Log.v(TAG, "Processing attachment: $attachmentId")
val attachDb = DatabaseFactory.getAttachmentDatabase(context)
val attachment = attachDb.getAttachment(attachmentId)
if (attachment == null) {
throw IllegalStateException("Cannot find attachment with the ID $attachmentId")
}
if (!attachment.contentType.startsWith("audio/")) {
throw IllegalStateException("Attachment $attachmentId is not of audio type.")
}
// Check if the audio extras already exist.
if (attachDb.getAttachmentAudioExtras(attachmentId) != null) return
fun extractAttachmentRandomSeed(attachment: Attachment): Int {
return when {
attachment.digest != null -> attachment.digest!!.sum()
attachment.fileName != null -> attachment.fileName.hashCode()
else -> attachment.hashCode()
}
}
fun generateFakeRms(seed: Int, frames: Int = VISUAL_RMS_FRAMES): ByteArray {
return ByteArray(frames).apply { Random(seed.toLong()).nextBytes(this) }
}
var rmsValues: ByteArray
var totalDurationMs: Long = DatabaseAttachmentAudioExtras.DURATION_UNDEFINED
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
// Due to API version incompatibility, we just display some random waveform for older API.
rmsValues = generateFakeRms(extractAttachmentRandomSeed(attachment))
} else {
try {
@Suppress("BlockingMethodInNonBlockingContext")
val decodedAudio = PartAuthority.getAttachmentStream(context, attachment.dataUri!!).use {
DecodedAudio.create(InputStreamMediaDataSource(it))
}
rmsValues = decodedAudio.calculateRms(VISUAL_RMS_FRAMES)
totalDurationMs = (decodedAudio.totalDuration / 1000.0).toLong()
} catch (e: Exception) {
Log.w(TAG, "Failed to decode sample values for the audio attachment \"${attachment.fileName}\".", e)
rmsValues = generateFakeRms(extractAttachmentRandomSeed(attachment))
}
}
attachDb.setAttachmentAudioExtras(DatabaseAttachmentAudioExtras(
attachmentId,
rmsValues,
totalDurationMs
))
EventBus.getDefault().post(AudioExtrasUpdatedEvent(attachmentId))
}
class Factory : Job.Factory<PrepareAttachmentAudioExtrasJob> {
override fun create(parameters: Parameters, data: Data): PrepareAttachmentAudioExtrasJob {
return PrepareAttachmentAudioExtrasJob(parameters, data.getParcelable(DATA_ATTACH_ID, AttachmentId.CREATOR))
}
}
/** Gets dispatched once the audio extras have been updated. */
data class AudioExtrasUpdatedEvent(val attachmentId: AttachmentId)
@RequiresApi(Build.VERSION_CODES.M)
private class InputStreamMediaDataSource: MediaDataSource {
private val data: ByteArray
constructor(inputStream: InputStream): super() {
this.data = inputStream.readBytes()
}
override fun readAt(position: Long, buffer: ByteArray, offset: Int, size: Int): Int {
val length: Int = data.size
if (position >= length) {
return -1 // -1 indicates EOF
}
var actualSize = size
if (position + size > length) {
actualSize -= (position + size - length).toInt()
}
System.arraycopy(data, position.toInt(), buffer, offset, actualSize)
return actualSize
}
override fun getSize(): Long {
return data.size.toLong()
}
override fun close() {
// We don't need to close the wrapped stream.
}
}
}

View file

@ -0,0 +1,368 @@
package org.thoughtcrime.securesms.loki.utilities.audio
import android.media.AudioFormat
import android.media.MediaCodec
import android.media.MediaDataSource
import android.media.MediaExtractor
import android.media.MediaFormat
import android.os.Build
import androidx.annotation.RequiresApi
import java.io.FileDescriptor
import java.io.IOException
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.ShortBuffer
import kotlin.jvm.Throws
import kotlin.math.ceil
import kotlin.math.roundToInt
import kotlin.math.sqrt
/**
* Decodes the audio data and provides access to its sample data.
* We need this to extract RMS values for waveform visualization.
*
* Use static [DecodedAudio.create] methods to instantiate a [DecodedAudio].
*
* Partially based on the old [Google's Ringdroid project]
* (https://github.com/google/ringdroid/blob/master/app/src/main/java/com/ringdroid/soundfile/SoundFile.java).
*
* *NOTE:* This class instance creation might be pretty slow (depends on the source audio file size).
* It's recommended to instantiate it in the background.
*/
@Suppress("MemberVisibilityCanBePrivate")
class DecodedAudio {
companion object {
@JvmStatic
@Throws(IOException::class)
fun create(fd: FileDescriptor, startOffset: Long, size: Long): DecodedAudio {
val mediaExtractor = MediaExtractor().apply { setDataSource(fd, startOffset, size) }
return DecodedAudio(mediaExtractor, size)
}
@JvmStatic
@RequiresApi(api = Build.VERSION_CODES.M)
@Throws(IOException::class)
fun create(dataSource: MediaDataSource): DecodedAudio {
val mediaExtractor = MediaExtractor().apply { setDataSource(dataSource) }
return DecodedAudio(mediaExtractor, dataSource.size)
}
}
val dataSize: Long
/** Average bit rate in kbps. */
val avgBitRate: Int
val sampleRate: Int
/** In microseconds. */
val totalDuration: Long
val channels: Int
/** Total number of samples per channel in audio file. */
val numSamples: Int
val samples: ShortBuffer
get() {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
Build.VERSION.SDK_INT <= Build.VERSION_CODES.N_MR1
) {
// Hack for Nougat where asReadOnlyBuffer fails to respect byte ordering.
// See https://code.google.com/p/android/issues/detail?id=223824
decodedSamples
} else {
decodedSamples.asReadOnlyBuffer()
}
}
/**
* Shared buffer with mDecodedBytes.
* Has the following format:
* {s1c1, s1c2, ..., s1cM, s2c1, ..., s2cM, ..., sNc1, ..., sNcM}
* where sicj is the ith sample of the jth channel (a sample is a signed short)
* M is the number of channels (e.g. 2 for stereo) and N is the number of samples per channel.
*/
private val decodedSamples: ShortBuffer
@Throws(IOException::class)
private constructor(extractor: MediaExtractor, size: Long) {
dataSize = size
var mediaFormat: MediaFormat? = null
// Find and select the first audio track present in the file.
for (trackIndex in 0 until extractor.trackCount) {
val format = extractor.getTrackFormat(trackIndex)
if (format.getString(MediaFormat.KEY_MIME)!!.startsWith("audio/")) {
extractor.selectTrack(trackIndex)
mediaFormat = format
break
}
}
if (mediaFormat == null) {
throw IOException("No audio track found in the data source.")
}
channels = mediaFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
sampleRate = mediaFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE)
// On some old APIs (23) this field might be missing.
totalDuration = if (mediaFormat.containsKey(MediaFormat.KEY_DURATION)) {
mediaFormat.getLong(MediaFormat.KEY_DURATION)
} else {
-1L
}
// Expected total number of samples per channel.
val expectedNumSamples = if (totalDuration >= 0) {
((totalDuration / 1000000f) * sampleRate + 0.5f).toInt()
} else {
Int.MAX_VALUE
}
val codec = MediaCodec.createDecoderByType(mediaFormat.getString(MediaFormat.KEY_MIME)!!)
codec.configure(mediaFormat, null, null, 0)
codec.start()
// Check if the track is in PCM 16 bit encoding.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
try {
val pcmEncoding = codec.outputFormat.getInteger(MediaFormat.KEY_PCM_ENCODING)
if (pcmEncoding != AudioFormat.ENCODING_PCM_16BIT) {
throw IOException("Unsupported PCM encoding code: $pcmEncoding")
}
} catch (e: NullPointerException) {
// If KEY_PCM_ENCODING is not specified, means it's ENCODING_PCM_16BIT.
}
}
var decodedSamplesSize: Int = 0 // size of the output buffer containing decoded samples.
var decodedSamples: ByteArray? = null
var sampleSize: Int
val info = MediaCodec.BufferInfo()
var presentationTime: Long
var totalSizeRead: Int = 0
var doneReading = false
// Set the size of the decoded samples buffer to 1MB (~6sec of a stereo stream at 44.1kHz).
// For longer streams, the buffer size will be increased later on, calculating a rough
// estimate of the total size needed to store all the samples in order to resize the buffer
// only once.
var decodedBytes: ByteBuffer = ByteBuffer.allocate(1 shl 20)
var firstSampleData = true
while (true) {
// read data from file and feed it to the decoder input buffers.
val inputBufferIndex: Int = codec.dequeueInputBuffer(100)
if (!doneReading && inputBufferIndex >= 0) {
sampleSize = extractor.readSampleData(codec.getInputBuffer(inputBufferIndex)!!, 0)
if (firstSampleData
&& mediaFormat.getString(MediaFormat.KEY_MIME)!! == "audio/mp4a-latm"
&& sampleSize == 2
) {
// For some reasons on some devices (e.g. the Samsung S3) you should not
// provide the first two bytes of an AAC stream, otherwise the MediaCodec will
// crash. These two bytes do not contain music data but basic info on the
// stream (e.g. channel configuration and sampling frequency), and skipping them
// seems OK with other devices (MediaCodec has already been configured and
// already knows these parameters).
extractor.advance()
totalSizeRead += sampleSize
} else if (sampleSize < 0) {
// All samples have been read.
codec.queueInputBuffer(
inputBufferIndex, 0, 0, -1, MediaCodec.BUFFER_FLAG_END_OF_STREAM
)
doneReading = true
} else {
presentationTime = extractor.sampleTime
codec.queueInputBuffer(inputBufferIndex, 0, sampleSize, presentationTime, 0)
extractor.advance()
totalSizeRead += sampleSize
}
firstSampleData = false
}
// Get decoded stream from the decoder output buffers.
val outputBufferIndex: Int = codec.dequeueOutputBuffer(info, 100)
if (outputBufferIndex >= 0 && info.size > 0) {
if (decodedSamplesSize < info.size) {
decodedSamplesSize = info.size
decodedSamples = ByteArray(decodedSamplesSize)
}
val outputBuffer: ByteBuffer = codec.getOutputBuffer(outputBufferIndex)!!
outputBuffer.get(decodedSamples!!, 0, info.size)
outputBuffer.clear()
// Check if buffer is big enough. Resize it if it's too small.
if (decodedBytes.remaining() < info.size) {
// Getting a rough estimate of the total size, allocate 20% more, and
// make sure to allocate at least 5MB more than the initial size.
val position = decodedBytes.position()
var newSize = ((position * (1.0 * dataSize / totalSizeRead)) * 1.2).toInt()
if (newSize - position < info.size + 5 * (1 shl 20)) {
newSize = position + info.size + 5 * (1 shl 20)
}
var newDecodedBytes: ByteBuffer? = null
// Try to allocate memory. If we are OOM, try to run the garbage collector.
var retry = 10
while (retry > 0) {
try {
newDecodedBytes = ByteBuffer.allocate(newSize)
break
} catch (e: OutOfMemoryError) {
// setting android:largeHeap="true" in <application> seem to help not
// reaching this section.
retry--
}
}
if (retry == 0) {
// Failed to allocate memory... Stop reading more data and finalize the
// instance with the data decoded so far.
break
}
decodedBytes.rewind()
newDecodedBytes!!.put(decodedBytes)
decodedBytes = newDecodedBytes
decodedBytes.position(position)
}
decodedBytes.put(decodedSamples, 0, info.size)
codec.releaseOutputBuffer(outputBufferIndex, false)
}
if ((info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0
|| (decodedBytes.position() / (2 * channels)) >= expectedNumSamples
) {
// We got all the decoded data from the decoder. Stop here.
// Theoretically dequeueOutputBuffer(info, ...) should have set info.flags to
// MediaCodec.BUFFER_FLAG_END_OF_STREAM. However some phones (e.g. Samsung S3)
// won't do that for some files (e.g. with mono AAC files), in which case subsequent
// calls to dequeueOutputBuffer may result in the application crashing, without
// even an exception being thrown... Hence the second check.
// (for mono AAC files, the S3 will actually double each sample, as if the stream
// was stereo. The resulting stream is half what it's supposed to be and with a much
// lower pitch.)
break
}
}
numSamples = decodedBytes.position() / (channels * 2) // One sample = 2 bytes.
decodedBytes.rewind()
decodedBytes.order(ByteOrder.LITTLE_ENDIAN)
this.decodedSamples = decodedBytes.asShortBuffer()
avgBitRate = ((dataSize * 8) * (sampleRate.toFloat() / numSamples) / 1000).toInt()
extractor.release()
codec.stop()
codec.release()
}
fun calculateRms(maxFrames: Int): ByteArray {
return calculateRms(this.samples, this.numSamples, this.channels, maxFrames)
}
}
/**
* Computes audio RMS values for the first channel only.
*
* A typical RMS calculation algorithm is:
* 1. Square each sample
* 2. Sum the squared samples
* 3. Divide the sum of the squared samples by the number of samples
* 4. Take the square root of step 3., the mean of the squared samples
*
* @param maxFrames Defines amount of output RMS frames.
* If number of samples per channel is less than "maxFrames",
* the result array will match the source sample size instead.
*
* @return normalized RMS values as a signed byte array.
*/
private fun calculateRms(samples: ShortBuffer, numSamples: Int, channels: Int, maxFrames: Int): ByteArray {
val numFrames: Int
val frameStep: Float
val samplesPerChannel = numSamples / channels
if (samplesPerChannel <= maxFrames) {
frameStep = 1f
numFrames = samplesPerChannel
} else {
frameStep = numSamples / maxFrames.toFloat()
numFrames = maxFrames
}
val rmsValues = FloatArray(numFrames)
var squaredFrameSum = 0.0
var currentFrameIdx = 0
fun calculateFrameRms(nextFrameIdx: Int) {
rmsValues[currentFrameIdx] = sqrt(squaredFrameSum.toFloat())
// Advance to the next frame.
squaredFrameSum = 0.0
currentFrameIdx = nextFrameIdx
}
(0 until numSamples * channels step channels).forEach { sampleIdx ->
val channelSampleIdx = sampleIdx / channels
val frameIdx = (channelSampleIdx / frameStep).toInt()
if (currentFrameIdx != frameIdx) {
// Calculate RMS value for the previous frame.
calculateFrameRms(frameIdx)
}
val samplesInCurrentFrame = ceil((currentFrameIdx + 1) * frameStep) - ceil(currentFrameIdx * frameStep)
squaredFrameSum += (samples[sampleIdx] * samples[sampleIdx]) / samplesInCurrentFrame
}
// Calculate RMS value for the last frame.
calculateFrameRms(-1)
// smoothArray(rmsValues, 1.0f)
normalizeArray(rmsValues)
// Convert normalized result to a signed byte array.
return rmsValues.map { value -> normalizedFloatToByte(value) }.toByteArray()
}
/**
* Normalizes the array's values to [0..1] range.
*/
private fun normalizeArray(values: FloatArray) {
var maxValue = -Float.MAX_VALUE
var minValue = +Float.MAX_VALUE
values.forEach { value ->
if (value > maxValue) maxValue = value
if (value < minValue) minValue = value
}
val span = maxValue - minValue
if (span == 0f) {
values.indices.forEach { i -> values[i] = 0f }
return
}
values.indices.forEach { i -> values[i] = (values[i] - minValue) / span }
}
private fun smoothArray(values: FloatArray, neighborWeight: Float = 1f): FloatArray {
if (values.size < 3) return values
val result = FloatArray(values.size)
result[0] = values[0]
result[values.size - 1] == values[values.size - 1]
for (i in 1 until values.size - 1) {
result[i] = (values[i] + values[i - 1] * neighborWeight +
values[i + 1] * neighborWeight) / (1f + neighborWeight * 2f)
}
return result
}
/** Turns a signed byte into a [0..1] float. */
inline fun byteToNormalizedFloat(value: Byte): Float {
return (value + 128f) / 255f
}
/** Turns a [0..1] float into a signed byte. */
inline fun normalizedFloatToByte(value: Float): Byte {
return (255f * value - 128f).roundToInt().toByte()
}

View file

@ -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)
}
}
}

View file

@ -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)
}
}

View file

@ -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;

View file

@ -131,7 +131,7 @@ public abstract class Slide {
public @NonNull String getContentDescription() { return ""; }
public Attachment asAttachment() {
public @NonNull Attachment asAttachment() {
return attachment;
}

View file

@ -0,0 +1,32 @@
package org.thoughtcrime.securesms.util
import android.os.Parcel
import android.os.Parcelable
object ParcelableUtil {
@JvmStatic
fun marshall(parcelable: Parcelable): ByteArray {
val parcel = Parcel.obtain()
parcelable.writeToParcel(parcel, 0)
val bytes = parcel.marshall()
parcel.recycle()
return bytes
}
@JvmStatic
fun unmarshall(bytes: ByteArray): Parcel {
val parcel = Parcel.obtain()
parcel.unmarshall(bytes, 0, bytes.size)
parcel.setDataPosition(0) // This is extremely important!
return parcel
}
@JvmStatic
fun <T> unmarshall(bytes: ByteArray, creator: Parcelable.Creator<T>): T {
val parcel: Parcel = ParcelableUtil.unmarshall(bytes)
val result = creator.createFromParcel(parcel)
parcel.recycle()
return result
}
}