diff --git a/build.gradle b/build.gradle
index 67c231b90b..f3fe570b03 100644
--- a/build.gradle
+++ b/build.gradle
@@ -151,6 +151,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"
@@ -183,7 +184,7 @@ dependencies {
testImplementation 'org.robolectric:shadows-multidex:4.2'
}
-def canonicalVersionCode = 111
+def canonicalVersionCode = 115
def canonicalVersionName = "1.6.2"
def postFixSize = 10
@@ -196,7 +197,7 @@ def abiPostFix = ['armeabi-v7a' : 1,
android {
flavorDimensions "none"
compileSdkVersion 29
- buildToolsVersion '28.0.3'
+ buildToolsVersion '29.0.3'
useLibrary 'org.apache.http.legacy'
dexOptions {
diff --git a/res/drawable/circle_tintable_4dp_inset.xml b/res/drawable/circle_tintable_4dp_inset.xml
new file mode 100644
index 0000000000..92b0e0830c
--- /dev/null
+++ b/res/drawable/circle_tintable_4dp_inset.xml
@@ -0,0 +1,4 @@
+
+
\ No newline at end of file
diff --git a/res/layout/conversation_activity_attachment_editor_stub.xml b/res/layout/conversation_activity_attachment_editor_stub.xml
index 034400a61a..3ee19aac0b 100644
--- a/res/layout/conversation_activity_attachment_editor_stub.xml
+++ b/res/layout/conversation_activity_attachment_editor_stub.xml
@@ -32,7 +32,7 @@
app:minHeight="100dp"
app:maxHeight="300dp"/>
-
+ app:waveformFillColor="?conversation_item_audio_seek_bar_color_outgoing"
+ app:waveformBackgroundColor="?conversation_item_audio_seek_bar_background_color"/>
-
diff --git a/res/layout/conversation_item_sent_audio.xml b/res/layout/conversation_item_sent_audio.xml
index d298e5f424..cb7889fa1f 100644
--- a/res/layout/conversation_item_sent_audio.xml
+++ b/res/layout/conversation_item_sent_audio.xml
@@ -1,10 +1,11 @@
-
diff --git a/res/layout/audio_view.xml b/res/layout/message_audio_view.xml
similarity index 54%
rename from res/layout/audio_view.xml
rename to res/layout/message_audio_view.xml
index e5a33d9a41..0bb4abf2a5 100644
--- a/res/layout/audio_view.xml
+++ b/res/layout/message_audio_view.xml
@@ -2,7 +2,7 @@
+ tools:context="org.thoughtcrime.securesms.loki.views.MessageAudioView">
-
+ android:layout_gravity="center_vertical"
+ android:min="0"
+ android:max="100"
+ tools:visibility="gone"
+ tools:backgroundTint="@android:color/black"
+ tools:indeterminateTint="@android:color/white"/>
-
+
+
+
-
-
\ No newline at end of file
diff --git a/res/values-notnight-v21/themes.xml b/res/values-notnight-v21/themes.xml
index e8d5e80e71..2807ff2ed7 100644
--- a/res/values-notnight-v21/themes.xml
+++ b/res/values-notnight-v21/themes.xml
@@ -27,6 +27,9 @@
- @color/core_grey_60
- @drawable/ic_outline_info_24
+
+ - @color/accent
+ - @color/white
diff --git a/src/org/thoughtcrime/securesms/ApplicationContext.java b/src/org/thoughtcrime/securesms/ApplicationContext.java
index 29a668776d..44531d4c4b 100644
--- a/src/org/thoughtcrime/securesms/ApplicationContext.java
+++ b/src/org/thoughtcrime/securesms/ApplicationContext.java
@@ -42,6 +42,7 @@ import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
import org.thoughtcrime.securesms.crypto.storage.TextSecureSessionStore;
import org.thoughtcrime.securesms.database.Address;
import org.thoughtcrime.securesms.database.DatabaseFactory;
+import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.dependencies.AxolotlStorageModule;
import org.thoughtcrime.securesms.dependencies.InjectableType;
import org.thoughtcrime.securesms.dependencies.SignalCommunicationModule;
@@ -305,7 +306,8 @@ public class ApplicationContext extends MultiDexApplication implements Dependenc
byte[] userPrivateKey = IdentityKeyUtil.getIdentityKeyPair(this).getPrivateKey().serialize();
LokiAPIDatabase apiDB = DatabaseFactory.getLokiAPIDatabase(this);
LokiUserDatabase userDB = DatabaseFactory.getLokiUserDatabase(this);
- publicChatAPI = new PublicChatAPI(userPublicKey, userPrivateKey, apiDB, userDB);
+ GroupDatabase groupDB = DatabaseFactory.getGroupDatabase(this);
+ publicChatAPI = new PublicChatAPI(userPublicKey, userPrivateKey, apiDB, userDB, groupDB);
return publicChatAPI;
}
diff --git a/src/org/thoughtcrime/securesms/attachments/AttachmentId.java b/src/org/thoughtcrime/securesms/attachments/AttachmentId.java
index 3d43c84195..6f5160d75a 100644
--- a/src/org/thoughtcrime/securesms/attachments/AttachmentId.java
+++ b/src/org/thoughtcrime/securesms/attachments/AttachmentId.java
@@ -1,12 +1,15 @@
package org.thoughtcrime.securesms.attachments;
+import android.os.Parcel;
+import android.os.Parcelable;
+
import androidx.annotation.NonNull;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.thoughtcrime.securesms.util.Util;
-public class AttachmentId {
+public class AttachmentId implements Parcelable {
@JsonProperty
private final long rowId;
@@ -54,4 +57,33 @@ public class AttachmentId {
public int hashCode() {
return Util.hashCode(rowId, uniqueId);
}
+
+ //region Parcelable implementation.
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeLong(rowId);
+ dest.writeLong(uniqueId);
+ }
+
+ public static final Parcelable.Creator CREATOR =
+ new Parcelable.Creator() {
+
+ @Override
+ public AttachmentId createFromParcel(Parcel in) {
+ long rowId = in.readLong();
+ long uniqueId = in.readLong();
+ return new AttachmentId(rowId, uniqueId);
+ }
+
+ @Override
+ public AttachmentId[] newArray(int size) {
+ return new AttachmentId[size];
+ }
+ };
+ //endregion
}
diff --git a/src/org/thoughtcrime/securesms/attachments/DatabaseAttachmentAudioExtras.kt b/src/org/thoughtcrime/securesms/attachments/DatabaseAttachmentAudioExtras.kt
new file mode 100644
index 0000000000..f10a02272e
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/attachments/DatabaseAttachmentAudioExtras.kt
@@ -0,0 +1,23 @@
+package org.thoughtcrime.securesms.attachments
+
+data class DatabaseAttachmentAudioExtras(
+ val attachmentId: AttachmentId,
+ /** Small amount of normalized audio byte samples to visualise the content (e.g. draw waveform). */
+ val visualSamples: ByteArray,
+ /** Duration of the audio track in milliseconds. May be [DURATION_UNDEFINED] when it is not known. */
+ val durationMs: Long = DURATION_UNDEFINED) {
+
+ companion object {
+ const val DURATION_UNDEFINED = -1L
+ }
+
+ override fun equals(other: Any?): Boolean {
+ return other != null &&
+ other is DatabaseAttachmentAudioExtras &&
+ other.attachmentId == attachmentId
+ }
+
+ override fun hashCode(): Int {
+ return attachmentId.hashCode()
+ }
+}
\ No newline at end of file
diff --git a/src/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java b/src/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java
index fa65d129e4..a737e3c264 100644
--- a/src/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java
+++ b/src/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java
@@ -32,6 +32,7 @@ import com.google.android.exoplayer2.source.MediaSource;
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector;
import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory;
+import org.jetbrains.annotations.NotNull;
import org.thoughtcrime.securesms.attachments.AttachmentServer;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.AudioSlide;
@@ -150,7 +151,11 @@ public class AudioSlidePlayer implements SensorEventListener {
case Player.STATE_ENDED:
Log.i(TAG, "onComplete");
+
+ long millis = mediaPlayer.getDuration();
+
synchronized (AudioSlidePlayer.this) {
+ mediaPlayer.release();
mediaPlayer = null;
if (audioAttachmentServer != null) {
@@ -167,6 +172,7 @@ public class AudioSlidePlayer implements SensorEventListener {
}
}
+ notifyOnProgress(1.0, millis);
notifyOnStop();
progressEventHandler.removeMessages(0);
}
@@ -233,6 +239,20 @@ public class AudioSlidePlayer implements SensorEventListener {
}
}
+ public synchronized boolean isReady() {
+ if (mediaPlayer == null) return false;
+
+ return mediaPlayer.getPlaybackState() == Player.STATE_READY && mediaPlayer.getPlayWhenReady();
+ }
+
+ public synchronized void seekTo(double progress) throws IOException {
+ if (mediaPlayer == null || !isReady()) {
+ play(progress);
+ } else {
+ mediaPlayer.seekTo((long) (mediaPlayer.getDuration() * progress));
+ }
+ }
+
public void setListener(@NonNull Listener listener) {
this.listener = new WeakReference<>(listener);
@@ -256,30 +276,15 @@ public class AudioSlidePlayer implements SensorEventListener {
}
private void notifyOnStart() {
- Util.runOnMain(new Runnable() {
- @Override
- public void run() {
- getListener().onStart();
- }
- });
+ Util.runOnMain(() -> getListener().onPlayerStart(AudioSlidePlayer.this));
}
private void notifyOnStop() {
- Util.runOnMain(new Runnable() {
- @Override
- public void run() {
- getListener().onStop();
- }
- });
+ Util.runOnMain(() -> getListener().onPlayerStop(AudioSlidePlayer.this));
}
private void notifyOnProgress(final double progress, final long millis) {
- Util.runOnMain(new Runnable() {
- @Override
- public void run() {
- getListener().onProgress(progress, millis);
- }
- });
+ Util.runOnMain(() -> getListener().onPlayerProgress(AudioSlidePlayer.this, progress, millis));
}
private @NonNull Listener getListener() {
@@ -288,11 +293,11 @@ public class AudioSlidePlayer implements SensorEventListener {
if (listener != null) return listener;
else return new Listener() {
@Override
- public void onStart() {}
+ public void onPlayerStart(@NotNull AudioSlidePlayer player) { }
@Override
- public void onStop() {}
+ public void onPlayerStop(@NotNull AudioSlidePlayer player) { }
@Override
- public void onProgress(double progress, long millis) {}
+ public void onPlayerProgress(@NotNull AudioSlidePlayer player, double progress, long millis) { }
};
}
@@ -355,9 +360,9 @@ public class AudioSlidePlayer implements SensorEventListener {
}
public interface Listener {
- void onStart();
- void onStop();
- void onProgress(double progress, long millis);
+ void onPlayerStart(@NonNull AudioSlidePlayer player);
+ void onPlayerStop(@NonNull AudioSlidePlayer player);
+ void onPlayerProgress(@NonNull AudioSlidePlayer player, double progress, long millis);
}
private static class ProgressEventHandler extends Handler {
diff --git a/src/org/thoughtcrime/securesms/components/AudioView.java b/src/org/thoughtcrime/securesms/components/AudioView.java
deleted file mode 100644
index 9e4c7c3e9a..0000000000
--- a/src/org/thoughtcrime/securesms/components/AudioView.java
+++ /dev/null
@@ -1,330 +0,0 @@
-package org.thoughtcrime.securesms.components;
-
-import android.annotation.TargetApi;
-import android.content.Context;
-import android.content.res.ColorStateList;
-import android.content.res.TypedArray;
-import android.graphics.Color;
-import android.graphics.PorterDuff;
-import android.graphics.drawable.AnimatedVectorDrawable;
-import android.os.Build;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import android.util.AttributeSet;
-import android.view.MotionEvent;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.FrameLayout;
-import android.widget.ImageView;
-import android.widget.SeekBar;
-import android.widget.TextView;
-
-import com.pnikosis.materialishprogress.ProgressWheel;
-
-import org.greenrobot.eventbus.EventBus;
-import org.greenrobot.eventbus.Subscribe;
-import org.greenrobot.eventbus.ThreadMode;
-import org.thoughtcrime.securesms.audio.AudioSlidePlayer;
-import org.thoughtcrime.securesms.database.AttachmentDatabase;
-import org.thoughtcrime.securesms.events.PartProgressEvent;
-import org.thoughtcrime.securesms.logging.Log;
-import org.thoughtcrime.securesms.mms.AudioSlide;
-import org.thoughtcrime.securesms.mms.SlideClickListener;
-
-import java.io.IOException;
-import java.util.concurrent.TimeUnit;
-
-import network.loki.messenger.R;
-
-
-public class AudioView extends FrameLayout implements AudioSlidePlayer.Listener {
-
- private static final String TAG = AudioView.class.getSimpleName();
-
- private final @NonNull AnimatingToggle controlToggle;
- private final @NonNull ViewGroup container;
- private final @NonNull ImageView playButton;
- private final @NonNull ImageView pauseButton;
- private final @NonNull ImageView downloadButton;
- private final @NonNull ProgressWheel downloadProgress;
- private final @NonNull SeekBar seekBar;
- private final @NonNull TextView timestamp;
-
- private @Nullable SlideClickListener downloadListener;
- private @Nullable AudioSlidePlayer audioSlidePlayer;
- private int backwardsCounter;
-
- public AudioView(Context context) {
- this(context, null);
- }
-
- public AudioView(Context context, AttributeSet attrs) {
- this(context, attrs, 0);
- }
-
- public AudioView(Context context, AttributeSet attrs, int defStyleAttr) {
- super(context, attrs, defStyleAttr);
- inflate(context, R.layout.audio_view, this);
-
- this.container = (ViewGroup) findViewById(R.id.audio_widget_container);
- this.controlToggle = (AnimatingToggle) findViewById(R.id.control_toggle);
- this.playButton = (ImageView) findViewById(R.id.play);
- this.pauseButton = (ImageView) findViewById(R.id.pause);
- this.downloadButton = (ImageView) findViewById(R.id.download);
- this.downloadProgress = (ProgressWheel) findViewById(R.id.download_progress);
- this.seekBar = (SeekBar) findViewById(R.id.seek);
- this.timestamp = (TextView) findViewById(R.id.timestamp);
-
- this.playButton.setOnClickListener(new PlayClickedListener());
- this.pauseButton.setOnClickListener(new PauseClickedListener());
- this.seekBar.setOnSeekBarChangeListener(new SeekBarModifiedListener());
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
- this.playButton.setImageDrawable(context.getDrawable(R.drawable.play_icon));
- this.pauseButton.setImageDrawable(context.getDrawable(R.drawable.pause_icon));
- this.playButton.setBackground(context.getDrawable(R.drawable.ic_circle_fill_white_48dp));
- this.pauseButton.setBackground(context.getDrawable(R.drawable.ic_circle_fill_white_48dp));
- }
-
- if (attrs != null) {
- TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.AudioView, 0, 0);
- setTint(typedArray.getColor(R.styleable.AudioView_foregroundTintColor, Color.WHITE),
- typedArray.getColor(R.styleable.AudioView_backgroundTintColor, Color.WHITE));
- container.setBackgroundColor(typedArray.getColor(R.styleable.AudioView_widgetBackground, Color.TRANSPARENT));
- typedArray.recycle();
- }
- }
-
- @Override
- protected void onAttachedToWindow() {
- super.onAttachedToWindow();
- if (!EventBus.getDefault().isRegistered(this)) EventBus.getDefault().register(this);
- }
-
- @Override
- protected void onDetachedFromWindow() {
- super.onDetachedFromWindow();
- EventBus.getDefault().unregister(this);
- }
-
- public void setAudio(final @NonNull AudioSlide audio,
- final boolean showControls)
- {
-
- if (showControls && audio.isPendingDownload()) {
- controlToggle.displayQuick(downloadButton);
- seekBar.setEnabled(false);
- downloadButton.setOnClickListener(new DownloadClickedListener(audio));
- if (downloadProgress.isSpinning()) downloadProgress.stopSpinning();
- } else if (showControls && audio.getTransferState() == AttachmentDatabase.TRANSFER_PROGRESS_STARTED) {
- controlToggle.displayQuick(downloadProgress);
- seekBar.setEnabled(false);
- downloadProgress.spin();
- } else {
- controlToggle.displayQuick(playButton);
- seekBar.setEnabled(true);
- if (downloadProgress.isSpinning()) downloadProgress.stopSpinning();
- }
-
- this.audioSlidePlayer = AudioSlidePlayer.createFor(getContext(), audio, this);
- }
-
- public void cleanup() {
- if (this.audioSlidePlayer != null && pauseButton.getVisibility() == View.VISIBLE) {
- this.audioSlidePlayer.stop();
- }
- }
-
- public void setDownloadClickListener(@Nullable SlideClickListener listener) {
- this.downloadListener = listener;
- }
-
- @Override
- public void onStart() {
- if (this.pauseButton.getVisibility() != View.VISIBLE) {
- togglePlayToPause();
- }
- }
-
- @Override
- public void onStop() {
- if (this.playButton.getVisibility() != View.VISIBLE) {
- togglePauseToPlay();
- }
-
- if (seekBar.getProgress() + 5 >= seekBar.getMax()) {
- backwardsCounter = 4;
- onProgress(0.0, 0);
- }
- }
-
- @Override
- public void setFocusable(boolean focusable) {
- super.setFocusable(focusable);
- this.playButton.setFocusable(focusable);
- this.pauseButton.setFocusable(focusable);
- this.seekBar.setFocusable(focusable);
- this.seekBar.setFocusableInTouchMode(focusable);
- this.downloadButton.setFocusable(focusable);
- }
-
- @Override
- public void setClickable(boolean clickable) {
- super.setClickable(clickable);
- this.playButton.setClickable(clickable);
- this.pauseButton.setClickable(clickable);
- this.seekBar.setClickable(clickable);
- this.seekBar.setOnTouchListener(clickable ? null : new TouchIgnoringListener());
- this.downloadButton.setClickable(clickable);
- }
-
- @Override
- public void setEnabled(boolean enabled) {
- super.setEnabled(enabled);
- this.playButton.setEnabled(enabled);
- this.pauseButton.setEnabled(enabled);
- this.seekBar.setEnabled(enabled);
- this.downloadButton.setEnabled(enabled);
- }
-
- @Override
- public void onProgress(double progress, long millis) {
- int seekProgress = (int)Math.floor(progress * this.seekBar.getMax());
-
- if (seekProgress > seekBar.getProgress() || backwardsCounter > 3) {
- backwardsCounter = 0;
- this.seekBar.setProgress(seekProgress);
- this.timestamp.setText(String.format("%02d:%02d",
- TimeUnit.MILLISECONDS.toMinutes(millis),
- TimeUnit.MILLISECONDS.toSeconds(millis)));
- } else {
- backwardsCounter++;
- }
- }
-
- public void setTint(int foregroundTint, int backgroundTint) {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
- this.playButton.setBackgroundTintList(ColorStateList.valueOf(foregroundTint));
- this.playButton.setImageTintList(ColorStateList.valueOf(backgroundTint));
- this.pauseButton.setBackgroundTintList(ColorStateList.valueOf(foregroundTint));
- this.pauseButton.setImageTintList(ColorStateList.valueOf(backgroundTint));
- } else {
- this.playButton.setColorFilter(foregroundTint, PorterDuff.Mode.SRC_IN);
- this.pauseButton.setColorFilter(foregroundTint, PorterDuff.Mode.SRC_IN);
- }
-
- this.downloadButton.setColorFilter(foregroundTint, PorterDuff.Mode.SRC_IN);
- this.downloadProgress.setBarColor(foregroundTint);
-
- this.timestamp.setTextColor(foregroundTint);
- this.seekBar.getProgressDrawable().setColorFilter(foregroundTint, PorterDuff.Mode.SRC_IN);
- this.seekBar.getThumb().setColorFilter(foregroundTint, PorterDuff.Mode.SRC_IN);
- }
-
- private double getProgress() {
- if (this.seekBar.getProgress() <= 0 || this.seekBar.getMax() <= 0) {
- return 0;
- } else {
- return (double)this.seekBar.getProgress() / (double)this.seekBar.getMax();
- }
- }
-
- private void togglePlayToPause() {
- controlToggle.displayQuick(pauseButton);
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
- AnimatedVectorDrawable playToPauseDrawable = (AnimatedVectorDrawable)getContext().getDrawable(R.drawable.play_to_pause_animation);
- pauseButton.setImageDrawable(playToPauseDrawable);
- playToPauseDrawable.start();
- }
- }
-
- private void togglePauseToPlay() {
- controlToggle.displayQuick(playButton);
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
- AnimatedVectorDrawable pauseToPlayDrawable = (AnimatedVectorDrawable)getContext().getDrawable(R.drawable.pause_to_play_animation);
- playButton.setImageDrawable(pauseToPlayDrawable);
- pauseToPlayDrawable.start();
- }
- }
-
- private class PlayClickedListener implements View.OnClickListener {
- @TargetApi(Build.VERSION_CODES.LOLLIPOP)
- @Override
- public void onClick(View v) {
- try {
- Log.d(TAG, "playbutton onClick");
- if (audioSlidePlayer != null) {
- togglePlayToPause();
- audioSlidePlayer.play(getProgress());
- }
- } catch (IOException e) {
- Log.w(TAG, e);
- }
- }
- }
-
- private class PauseClickedListener implements View.OnClickListener {
- @TargetApi(Build.VERSION_CODES.LOLLIPOP)
- @Override
- public void onClick(View v) {
- Log.d(TAG, "pausebutton onClick");
- if (audioSlidePlayer != null) {
- togglePauseToPlay();
- audioSlidePlayer.stop();
- }
- }
- }
-
- private class DownloadClickedListener implements View.OnClickListener {
- private final @NonNull AudioSlide slide;
-
- private DownloadClickedListener(@NonNull AudioSlide slide) {
- this.slide = slide;
- }
-
- @Override
- public void onClick(View v) {
- if (downloadListener != null) downloadListener.onClick(v, slide);
- }
- }
-
- private class SeekBarModifiedListener implements SeekBar.OnSeekBarChangeListener {
- @Override
- public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {}
-
- @Override
- public synchronized void onStartTrackingTouch(SeekBar seekBar) {
- if (audioSlidePlayer != null && pauseButton.getVisibility() == View.VISIBLE) {
- audioSlidePlayer.stop();
- }
- }
-
- @Override
- public synchronized void onStopTrackingTouch(SeekBar seekBar) {
- try {
- if (audioSlidePlayer != null && pauseButton.getVisibility() == View.VISIBLE) {
- audioSlidePlayer.play(getProgress());
- }
- } catch (IOException e) {
- Log.w(TAG, e);
- }
- }
- }
-
- private static class TouchIgnoringListener implements OnTouchListener {
- @Override
- public boolean onTouch(View v, MotionEvent event) {
- return true;
- }
- }
-
- @Subscribe(sticky = true, threadMode = ThreadMode.MAIN)
- public void onEventAsync(final PartProgressEvent event) {
- if (audioSlidePlayer != null && event.attachment.equals(audioSlidePlayer.getAudioSlide().asAttachment())) {
- downloadProgress.setInstantProgress(((float) event.progress) / event.total);
- }
- }
-
-}
diff --git a/src/org/thoughtcrime/securesms/conversation/ConversationActivity.java b/src/org/thoughtcrime/securesms/conversation/ConversationActivity.java
index 886d603999..12c8e337ce 100644
--- a/src/org/thoughtcrime/securesms/conversation/ConversationActivity.java
+++ b/src/org/thoughtcrime/securesms/conversation/ConversationActivity.java
@@ -213,6 +213,7 @@ import org.thoughtcrime.securesms.util.DateUtils;
import org.thoughtcrime.securesms.util.Dialogs;
import org.thoughtcrime.securesms.util.DynamicLanguage;
import org.thoughtcrime.securesms.util.ExpirationUtil;
+import org.thoughtcrime.securesms.util.GroupUtil;
import org.thoughtcrime.securesms.util.IdentityUtil;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.ServiceUtil;
@@ -227,6 +228,7 @@ import org.thoughtcrime.securesms.util.views.Stub;
import org.whispersystems.libsignal.InvalidMessageException;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.loki.api.opengroups.PublicChat;
+import org.whispersystems.signalservice.loki.api.opengroups.PublicChatAPI;
import org.whispersystems.signalservice.loki.protocol.mentions.Mention;
import org.whispersystems.signalservice.loki.protocol.mentions.MentionsManager;
import org.whispersystems.signalservice.loki.protocol.meta.SessionMetaProtocol;
@@ -458,7 +460,17 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
PublicChat publicChat = DatabaseFactory.getLokiThreadDatabase(this).getPublicChat(threadId);
if (publicChat != null) {
- ApplicationContext.getInstance(this).getPublicChatAPI().getChannelInfo(publicChat.getChannel(), publicChat.getServer()).success(displayName -> {
+ PublicChatAPI publicChatAPI = ApplicationContext.getInstance(this).getPublicChatAPI();
+ publicChatAPI.getChannelInfo(publicChat.getChannel(), publicChat.getServer()).success(info -> {
+ String groupId = GroupUtil.getEncodedOpenGroupId(publicChat.getId().getBytes());
+
+ publicChatAPI.updateProfileIfNeeded(
+ publicChat.getChannel(),
+ publicChat.getServer(),
+ groupId,
+ info,
+ false);
+
runOnUiThread(ConversationActivity.this::updateSubtitleTextView);
return Unit.INSTANCE;
});
diff --git a/src/org/thoughtcrime/securesms/conversation/ConversationItem.java b/src/org/thoughtcrime/securesms/conversation/ConversationItem.java
index 6bb7970de7..e98ab75a43 100644
--- a/src/org/thoughtcrime/securesms/conversation/ConversationItem.java
+++ b/src/org/thoughtcrime/securesms/conversation/ConversationItem.java
@@ -60,7 +60,7 @@ import org.thoughtcrime.securesms.MediaPreviewActivity;
import org.thoughtcrime.securesms.MessageDetailsActivity;
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
import org.thoughtcrime.securesms.components.AlertView;
-import org.thoughtcrime.securesms.components.AudioView;
+import org.thoughtcrime.securesms.loki.views.MessageAudioView;
import org.thoughtcrime.securesms.components.ConversationItemFooter;
import org.thoughtcrime.securesms.components.ConversationItemThumbnail;
import org.thoughtcrime.securesms.components.DocumentView;
@@ -161,7 +161,7 @@ public class ConversationItem extends TapJackingProofLinearLayout
private @NonNull Set batchSelected = new HashSet<>();
private Recipient conversationRecipient;
private Stub mediaThumbnailStub;
- private Stub audioViewStub;
+ private Stub audioViewStub;
private Stub documentViewStub;
private Stub sharedContactStub;
private Stub linkPreviewStub;
diff --git a/src/org/thoughtcrime/securesms/database/AttachmentDatabase.java b/src/org/thoughtcrime/securesms/database/AttachmentDatabase.java
index 63bdd4ca70..2e124f1579 100644
--- a/src/org/thoughtcrime/securesms/database/AttachmentDatabase.java
+++ b/src/org/thoughtcrime/securesms/database/AttachmentDatabase.java
@@ -24,11 +24,12 @@ import android.graphics.Bitmap;
import android.media.MediaMetadataRetriever;
import android.net.Uri;
import android.os.Build;
+import android.text.TextUtils;
+import android.util.Pair;
+
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
-import android.text.TextUtils;
-import android.util.Pair;
import com.bumptech.glide.Glide;
@@ -39,6 +40,7 @@ import org.json.JSONException;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.attachments.AttachmentId;
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
+import org.thoughtcrime.securesms.attachments.DatabaseAttachmentAudioExtras;
import org.thoughtcrime.securesms.crypto.AttachmentSecret;
import org.thoughtcrime.securesms.crypto.ClassicDecryptingPartInputStream;
import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream;
@@ -51,10 +53,10 @@ import org.thoughtcrime.securesms.mms.PartAuthority;
import org.thoughtcrime.securesms.stickers.StickerLocator;
import org.thoughtcrime.securesms.util.BitmapDecodingException;
import org.thoughtcrime.securesms.util.BitmapUtil;
+import org.thoughtcrime.securesms.util.ExternalStorageUtil;
import org.thoughtcrime.securesms.util.JsonUtils;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.MediaUtil.ThumbnailData;
-import org.thoughtcrime.securesms.util.ExternalStorageUtil;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.video.EncryptedMediaDataSource;
@@ -72,6 +74,8 @@ import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
+import kotlin.jvm.Synchronized;
+
public class AttachmentDatabase extends Database {
private static final String TAG = AttachmentDatabase.class.getSimpleName();
@@ -105,6 +109,9 @@ public class AttachmentDatabase extends Database {
static final String CAPTION = "caption";
public static final String URL = "url";
public static final String DIRECTORY = "parts";
+ // "audio/*" mime type only related columns.
+ static final String AUDIO_VISUAL_SAMPLES = "audio_visual_samples"; // Small amount of audio byte samples to visualise the content (e.g. draw waveform).
+ static final String AUDIO_DURATION = "audio_duration"; // Duration of the audio track in milliseconds.
public static final int TRANSFER_PROGRESS_DONE = 0;
public static final int TRANSFER_PROGRESS_STARTED = 1;
@@ -112,6 +119,7 @@ public class AttachmentDatabase extends Database {
public static final int TRANSFER_PROGRESS_FAILED = 3;
private static final String PART_ID_WHERE = ROW_ID + " = ? AND " + UNIQUE_ID + " = ?";
+ private static final String PART_AUDIO_ONLY_WHERE = CONTENT_TYPE + " LIKE \"audio/%\"";
private static final String[] PROJECTION = new String[] {ROW_ID,
MMS_ID, CONTENT_TYPE, NAME, CONTENT_DISPOSITION,
@@ -121,6 +129,8 @@ public class AttachmentDatabase extends Database {
QUOTE, DATA_RANDOM, THUMBNAIL_RANDOM, WIDTH, HEIGHT,
CAPTION, STICKER_PACK_ID, STICKER_PACK_KEY, STICKER_ID, URL};
+ private static final String[] PROJECTION_AUDIO_EXTRAS = new String[] {AUDIO_VISUAL_SAMPLES, AUDIO_DURATION};
+
public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ROW_ID + " INTEGER PRIMARY KEY, " +
MMS_ID + " INTEGER, " + "seq" + " INTEGER DEFAULT 0, " +
CONTENT_TYPE + " TEXT, " + NAME + " TEXT, " + "chset" + " INTEGER, " +
@@ -133,7 +143,8 @@ public class AttachmentDatabase extends Database {
VOICE_NOTE + " INTEGER DEFAULT 0, " + DATA_RANDOM + " BLOB, " + THUMBNAIL_RANDOM + " BLOB, " +
QUOTE + " INTEGER DEFAULT 0, " + WIDTH + " INTEGER DEFAULT 0, " + HEIGHT + " INTEGER DEFAULT 0, " +
CAPTION + " TEXT DEFAULT NULL, " + URL + " TEXT, " + STICKER_PACK_ID + " TEXT DEFAULT NULL, " +
- STICKER_PACK_KEY + " DEFAULT NULL, " + STICKER_ID + " INTEGER DEFAULT -1);";
+ STICKER_PACK_KEY + " DEFAULT NULL, " + STICKER_ID + " INTEGER DEFAULT -1," +
+ AUDIO_VISUAL_SAMPLES + " BLOB, " + AUDIO_DURATION + " INTEGER);";
public static final String[] CREATE_INDEXS = {
"CREATE INDEX IF NOT EXISTS part_mms_id_index ON " + TABLE_NAME + " (" + MMS_ID + ");",
@@ -822,6 +833,49 @@ public class AttachmentDatabase extends Database {
}
}
+ /**
+ * Retrieves the audio extra values associated with the attachment. Only "audio/*" mime type attachments are accepted.
+ * @return the related audio extras or null in case any of the audio extra columns are empty or the attachment is not an audio.
+ */
+ @Synchronized
+ public @Nullable DatabaseAttachmentAudioExtras getAttachmentAudioExtras(@NonNull AttachmentId attachmentId) {
+ try (Cursor cursor = databaseHelper.getReadableDatabase()
+ // We expect all the audio extra values to be present (not null) or reject the whole record.
+ .query(TABLE_NAME,
+ PROJECTION_AUDIO_EXTRAS,
+ PART_ID_WHERE +
+ " AND " + AUDIO_VISUAL_SAMPLES + " IS NOT NULL" +
+ " AND " + AUDIO_DURATION + " IS NOT NULL" +
+ " AND " + PART_AUDIO_ONLY_WHERE,
+ attachmentId.toStrings(),
+ null, null, null, "1")) {
+
+ if (cursor == null || !cursor.moveToFirst()) return null;
+
+ byte[] audioSamples = cursor.getBlob(cursor.getColumnIndexOrThrow(AUDIO_VISUAL_SAMPLES));
+ long duration = cursor.getLong(cursor.getColumnIndexOrThrow(AUDIO_DURATION));
+
+ return new DatabaseAttachmentAudioExtras(attachmentId, audioSamples, duration);
+ }
+ }
+
+ /**
+ * Updates audio extra columns for the "audio/*" mime type attachments only.
+ * @return true if the update operation was successful.
+ */
+ @Synchronized
+ public boolean setAttachmentAudioExtras(@NonNull DatabaseAttachmentAudioExtras extras) {
+ ContentValues values = new ContentValues();
+ values.put(AUDIO_VISUAL_SAMPLES, extras.getVisualSamples());
+ values.put(AUDIO_DURATION, extras.getDurationMs());
+
+ int alteredRows = databaseHelper.getWritableDatabase().update(TABLE_NAME,
+ values,
+ PART_ID_WHERE + " AND " + PART_AUDIO_ONLY_WHERE,
+ extras.getAttachmentId().toStrings());
+
+ return alteredRows > 0;
+ }
@VisibleForTesting
class ThumbnailFetchCallable implements Callable {
diff --git a/src/org/thoughtcrime/securesms/database/GroupDatabase.java b/src/org/thoughtcrime/securesms/database/GroupDatabase.java
index c14aff87d5..c5bd65e903 100644
--- a/src/org/thoughtcrime/securesms/database/GroupDatabase.java
+++ b/src/org/thoughtcrime/securesms/database/GroupDatabase.java
@@ -6,9 +6,10 @@ import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.graphics.Bitmap;
+import android.text.TextUtils;
+
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
-import android.text.TextUtils;
import com.annimon.stream.Stream;
@@ -21,6 +22,7 @@ import org.thoughtcrime.securesms.util.GroupUtil;
import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer;
+import org.whispersystems.signalservice.loki.database.LokiOpenGroupDatabaseProtocol;
import java.io.Closeable;
import java.io.IOException;
@@ -29,7 +31,7 @@ import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
-public class GroupDatabase extends Database {
+public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProtocol {
@SuppressWarnings("unused")
private static final String TAG = GroupDatabase.class.getSimpleName();
@@ -240,35 +242,37 @@ public class GroupDatabase extends Database {
notifyConversationListListeners();
}
- public void updateTitle(String groupId, String title) {
+ @Override
+ public void updateTitle(String groupID, String newValue) {
ContentValues contentValues = new ContentValues();
- contentValues.put(TITLE, title);
+ contentValues.put(TITLE, newValue);
databaseHelper.getWritableDatabase().update(TABLE_NAME, contentValues, GROUP_ID + " = ?",
- new String[] {groupId});
+ new String[] {groupID});
- Recipient recipient = Recipient.from(context, Address.fromSerialized(groupId), false);
- recipient.setName(title);
+ Recipient recipient = Recipient.from(context, Address.fromSerialized(groupID), false);
+ recipient.setName(newValue);
}
- public void updateAvatar(String groupId, Bitmap avatar) {
- updateAvatar(groupId, BitmapUtil.toByteArray(avatar));
+ public void updateProfilePicture(String groupID, Bitmap newValue) {
+ updateProfilePicture(groupID, BitmapUtil.toByteArray(newValue));
}
- public void updateAvatar(String groupId, byte[] avatar) {
+ @Override
+ public void updateProfilePicture(String groupID, byte[] newValue) {
long avatarId;
- if (avatar != null) avatarId = Math.abs(new SecureRandom().nextLong());
+ if (newValue != null) avatarId = Math.abs(new SecureRandom().nextLong());
else avatarId = 0;
ContentValues contentValues = new ContentValues(2);
- contentValues.put(AVATAR, avatar);
+ contentValues.put(AVATAR, newValue);
contentValues.put(AVATAR_ID, avatarId);
databaseHelper.getWritableDatabase().update(TABLE_NAME, contentValues, GROUP_ID + " = ?",
- new String[] {groupId});
+ new String[] {groupID});
- Recipient.applyCached(Address.fromSerialized(groupId), recipient -> recipient.setGroupAvatarId(avatarId == 0 ? null : avatarId));
+ Recipient.applyCached(Address.fromSerialized(groupID), recipient -> recipient.setGroupAvatarId(avatarId == 0 ? null : avatarId));
}
public void updateMembers(String groupId, List members) {
diff --git a/src/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/src/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java
index 82d8bf0a06..92109dde40 100644
--- a/src/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java
+++ b/src/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java
@@ -92,8 +92,10 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
private static final int lokiV13 = 34;
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 = 38;
- private static final int DATABASE_VERSION = lokiV15;
+ private static final int DATABASE_VERSION = lokiV17;
private static final String DATABASE_NAME = "signal.db";
private final Context context;
@@ -155,6 +157,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
db.execSQL(LokiAPIDatabase.getCreateSessionRequestSentTimestampTableCommand());
db.execSQL(LokiAPIDatabase.getCreateSessionRequestProcessedTimestampTableCommand());
db.execSQL(LokiAPIDatabase.getCreateOpenGroupPublicKeyTableCommand());
+ db.execSQL(LokiAPIDatabase.getCreateOpenGroupProfilePictureTableCommand());
db.execSQL(LokiPreKeyBundleDatabase.getCreateTableCommand());
db.execSQL(LokiPreKeyRecordDatabase.getCreateTableCommand());
db.execSQL(LokiMessageDatabase.getCreateMessageIDTableCommand());
@@ -632,6 +635,15 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
db.execSQL(SharedSenderKeysDatabase.getCreateOldClosedGroupRatchetTableCommand());
}
+ if (oldVersion < lokiV16) {
+ db.execSQL(LokiAPIDatabase.getCreateOpenGroupProfilePictureTableCommand());
+ }
+
+ if (oldVersion < lokiV17) {
+ db.execSQL("ALTER TABLE part ADD COLUMN audio_visual_samples BLOB");
+ db.execSQL("ALTER TABLE part ADD COLUMN audio_duration INTEGER");
+ }
+
db.setTransactionSuccessful();
} finally {
db.endTransaction();
diff --git a/src/org/thoughtcrime/securesms/dependencies/SignalCommunicationModule.java b/src/org/thoughtcrime/securesms/dependencies/SignalCommunicationModule.java
index 520761b536..a7449ca58d 100644
--- a/src/org/thoughtcrime/securesms/dependencies/SignalCommunicationModule.java
+++ b/src/org/thoughtcrime/securesms/dependencies/SignalCommunicationModule.java
@@ -159,6 +159,7 @@ public class SignalCommunicationModule {
DatabaseFactory.getLokiPreKeyBundleDatabase(context),
new SessionResetImplementation(context),
DatabaseFactory.getLokiUserDatabase(context),
+ DatabaseFactory.getGroupDatabase(context),
((ApplicationContext)context.getApplicationContext()).broadcaster);
} else {
this.messageSender.setMessagePipe(IncomingMessageObserver.getPipe(), IncomingMessageObserver.getUnidentifiedPipe());
diff --git a/src/org/thoughtcrime/securesms/groups/GroupManager.java b/src/org/thoughtcrime/securesms/groups/GroupManager.java
index 81f6feec33..694627f629 100644
--- a/src/org/thoughtcrime/securesms/groups/GroupManager.java
+++ b/src/org/thoughtcrime/securesms/groups/GroupManager.java
@@ -85,7 +85,7 @@ public class GroupManager {
groupDatabase.create(groupId, name, new LinkedList<>(memberAddresses), null, null, new LinkedList<>(adminAddresses));
if (!mms) {
- groupDatabase.updateAvatar(groupId, avatarBytes);
+ groupDatabase.updateProfilePicture(groupId, avatarBytes);
DatabaseFactory.getRecipientDatabase(context).setProfileSharing(groupRecipient, true);
return sendGroupUpdate(context, groupId, memberAddresses, name, avatarBytes, adminAddresses);
} else {
@@ -125,7 +125,7 @@ public class GroupManager {
memberAddresses.add(Address.fromSerialized(TextSecurePreferences.getLocalNumber(context)));
groupDatabase.create(groupId, name, new LinkedList<>(memberAddresses), null, null, new LinkedList<>());
- groupDatabase.updateAvatar(groupId, avatarBytes);
+ groupDatabase.updateProfilePicture(groupId, avatarBytes);
long threadID = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(groupRecipient, ThreadDatabase.DistributionTypes.CONVERSATION);
return new GroupActionResult(groupRecipient, threadID);
@@ -148,7 +148,7 @@ public class GroupManager {
groupDatabase.updateMembers(groupId, new LinkedList<>(memberAddresses));
groupDatabase.updateAdmins(groupId, new LinkedList<>(adminAddresses));
groupDatabase.updateTitle(groupId, name);
- groupDatabase.updateAvatar(groupId, avatarBytes);
+ groupDatabase.updateProfilePicture(groupId, avatarBytes);
if (!GroupUtil.isMmsGroup(groupId)) {
return sendGroupUpdate(context, groupId, memberAddresses, name, avatarBytes, adminAddresses);
diff --git a/src/org/thoughtcrime/securesms/jobmanager/Data.java b/src/org/thoughtcrime/securesms/jobmanager/Data.java
index 72e8508a5b..eff9fa1478 100644
--- a/src/org/thoughtcrime/securesms/jobmanager/Data.java
+++ b/src/org/thoughtcrime/securesms/jobmanager/Data.java
@@ -1,13 +1,19 @@
package org.thoughtcrime.securesms.jobmanager;
+import android.os.Parcelable;
+
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.fasterxml.jackson.annotation.JsonProperty;
+import org.thoughtcrime.securesms.util.ParcelableUtil;
+
import java.util.HashMap;
import java.util.Map;
+// TODO AC: For now parcelable objects utilize byteArrays field to store their data into.
+// Introduce a dedicated Map field specifically for parcelable needs.
public class Data {
public static final Data EMPTY = new Data.Builder().build();
@@ -213,6 +219,16 @@ public class Data {
return byteArrays.get(key);
}
+ public boolean hasParcelable(@NonNull String key) {
+ return byteArrays.containsKey(key);
+ }
+
+ public T getParcelable(@NonNull String key, @NonNull Parcelable.Creator creator) {
+ throwIfAbsent(byteArrays, key);
+ byte[] bytes = byteArrays.get(key);
+ return ParcelableUtil.unmarshall(bytes, creator);
+ }
+
private void throwIfAbsent(@NonNull Map map, @NonNull String key) {
if (!map.containsKey(key)) {
throw new IllegalStateException("Tried to retrieve a value with key '" + key + "', but it wasn't present.");
@@ -301,6 +317,12 @@ public class Data {
return this;
}
+ public Builder putParcelable(@NonNull String key, @NonNull Parcelable value) {
+ byte[] bytes = ParcelableUtil.marshall(value);
+ byteArrays.put(key, bytes);
+ return this;
+ }
+
public Data build() {
return new Data(strings,
stringArrays,
diff --git a/src/org/thoughtcrime/securesms/jobmanager/migration/WorkManagerFactoryMappings.java b/src/org/thoughtcrime/securesms/jobmanager/migration/WorkManagerFactoryMappings.java
index 1449ace743..9b1ec1f890 100644
--- a/src/org/thoughtcrime/securesms/jobmanager/migration/WorkManagerFactoryMappings.java
+++ b/src/org/thoughtcrime/securesms/jobmanager/migration/WorkManagerFactoryMappings.java
@@ -45,6 +45,7 @@ import org.thoughtcrime.securesms.jobs.TrimThreadJob;
import org.thoughtcrime.securesms.jobs.TypingSendJob;
import org.thoughtcrime.securesms.jobs.UpdateApkJob;
import org.thoughtcrime.securesms.loki.api.BackgroundPollJob;
+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;
@@ -102,6 +103,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/AvatarDownloadJob.java b/src/org/thoughtcrime/securesms/jobs/AvatarDownloadJob.java
index 0b2db43157..f2b95a46f0 100644
--- a/src/org/thoughtcrime/securesms/jobs/AvatarDownloadJob.java
+++ b/src/org/thoughtcrime/securesms/jobs/AvatarDownloadJob.java
@@ -95,7 +95,7 @@ public class AvatarDownloadJob extends BaseJob implements InjectableType {
InputStream inputStream = receiver.retrieveAttachment(pointer, attachment, MAX_AVATAR_SIZE);
Bitmap avatar = BitmapUtil.createScaledBitmap(context, new AttachmentModel(attachment, key, 0, digest), 500, 500);
- database.updateAvatar(groupId, avatar);
+ database.updateProfilePicture(groupId, avatar);
inputStream.close();
}
} catch (BitmapDecodingException | NonSuccessfulResponseCodeException | InvalidMessageException e) {
diff --git a/src/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/src/org/thoughtcrime/securesms/jobs/JobManagerFactories.java
index 6f3c34e310..8a89f0bcb6 100644
--- a/src/org/thoughtcrime/securesms/jobs/JobManagerFactories.java
+++ b/src/org/thoughtcrime/securesms/jobs/JobManagerFactories.java
@@ -15,6 +15,7 @@ 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.BackgroundPollJob;
+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;
@@ -79,6 +80,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 815e45ecc3..8a5cb4ae5c 100644
--- a/src/org/thoughtcrime/securesms/loki/activities/HomeActivity.kt
+++ b/src/org/thoughtcrime/securesms/loki/activities/HomeActivity.kt
@@ -26,11 +26,13 @@ import kotlinx.android.synthetic.main.activity_home.*
import network.loki.messenger.R
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
+import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.conversation.ConversationActivity
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.jobs.MultiDeviceBlockedUpdateJob
+import org.thoughtcrime.securesms.loki.api.PrepareAttachmentAudioExtrasJob
import org.thoughtcrime.securesms.loki.dialogs.ConversationOptionsBottomSheet
import org.thoughtcrime.securesms.loki.dialogs.LightThemeFeatureIntroBottomSheet
import org.thoughtcrime.securesms.loki.dialogs.MultiDeviceRemovalBottomSheet
@@ -340,20 +342,18 @@ class HomeActivity : PassphraseRequiredActionBarActivity, ConversationClickListe
val threadID = thread.threadId
val recipient = thread.recipient
val threadDB = DatabaseFactory.getThreadDatabase(this)
- val deleteThread = object : Runnable {
-
- override fun run() {
- AsyncTask.execute {
- val publicChat = DatabaseFactory.getLokiThreadDatabase(this@HomeActivity).getPublicChat(threadID)
- if (publicChat != null) {
- val apiDB = DatabaseFactory.getLokiAPIDatabase(this@HomeActivity)
- apiDB.removeLastMessageServerID(publicChat.channel, publicChat.server)
- apiDB.removeLastDeletionServerID(publicChat.channel, publicChat.server)
- ApplicationContext.getInstance(this@HomeActivity).publicChatAPI!!.leave(publicChat.channel, publicChat.server)
- }
- threadDB.deleteConversation(threadID)
- ApplicationContext.getInstance(this@HomeActivity).messageNotifier.updateNotification(this@HomeActivity)
+ val deleteThread = Runnable {
+ AsyncTask.execute {
+ val publicChat = DatabaseFactory.getLokiThreadDatabase(this@HomeActivity).getPublicChat(threadID)
+ if (publicChat != null) {
+ val apiDB = DatabaseFactory.getLokiAPIDatabase(this@HomeActivity)
+ apiDB.removeLastMessageServerID(publicChat.channel, publicChat.server)
+ apiDB.removeLastDeletionServerID(publicChat.channel, publicChat.server)
+ apiDB.clearOpenGroupProfilePictureURL(publicChat.channel, publicChat.server)
+ ApplicationContext.getInstance(this@HomeActivity).publicChatAPI!!.leave(publicChat.channel, publicChat.server)
}
+ threadDB.deleteConversation(threadID)
+ ApplicationContext.getInstance(this@HomeActivity).messageNotifier.updateNotification(this@HomeActivity)
}
}
val dialogMessage = if (recipient.isGroupRecipient) R.string.activity_home_leave_group_dialog_message else R.string.activity_home_delete_conversation_dialog_message
diff --git a/src/org/thoughtcrime/securesms/loki/activities/PathActivity.kt b/src/org/thoughtcrime/securesms/loki/activities/PathActivity.kt
index fa96d6b2a1..9de15d852b 100644
--- a/src/org/thoughtcrime/securesms/loki/activities/PathActivity.kt
+++ b/src/org/thoughtcrime/securesms/loki/activities/PathActivity.kt
@@ -82,7 +82,7 @@ class PathActivity : PassphraseRequiredActionBarActivity() {
private fun update(isAnimated: Boolean) {
pathRowsContainer.removeAllViews()
- if (OnionRequestAPI.paths.count() >= OnionRequestAPI.pathCount) {
+ if (OnionRequestAPI.paths.isNotEmpty()) {
val path = OnionRequestAPI.paths.firstOrNull() ?: return finish()
val dotAnimationRepeatInterval = path.count().toLong() * 1000 + 1000
val pathRows = path.mapIndexed { index, snode ->
diff --git a/src/org/thoughtcrime/securesms/loki/api/BackgroundPollJob.kt b/src/org/thoughtcrime/securesms/loki/api/BackgroundPollJob.kt
index 1cf97b3c64..7081cefe46 100644
--- a/src/org/thoughtcrime/securesms/loki/api/BackgroundPollJob.kt
+++ b/src/org/thoughtcrime/securesms/loki/api/BackgroundPollJob.kt
@@ -50,13 +50,16 @@ class BackgroundPollJob private constructor(parameters: Parameters) : BaseJob(pa
Log.d("Loki", "Performing background poll.")
val userPublicKey = TextSecurePreferences.getLocalNumber(context)
val promises = mutableListOf>()
- val promise = SnodeAPI.shared.getMessages(userPublicKey).map { envelopes ->
- envelopes.forEach {
- PushContentReceiveJob(context).processEnvelope(SignalServiceEnvelope(it), false)
+ if (!TextSecurePreferences.isUsingFCM(context)) {
+ Log.d("Loki", "Not using FCM; polling for contacts and closed groups.")
+ val promise = SnodeAPI.shared.getMessages(userPublicKey).map { envelopes ->
+ envelopes.forEach {
+ PushContentReceiveJob(context).processEnvelope(SignalServiceEnvelope(it), false)
+ }
}
+ promises.add(promise)
+ promises.addAll(ClosedGroupPoller.shared.pollOnce())
}
- promises.add(promise)
- promises.addAll(ClosedGroupPoller.shared.pollOnce())
val openGroups = DatabaseFactory.getLokiThreadDatabase(context).getAllPublicChats().map { it.value }
for (openGroup in openGroups) {
val poller = PublicChatPoller(context, openGroup)
diff --git a/src/org/thoughtcrime/securesms/loki/api/PrepareAttachmentAudioExtrasJob.kt b/src/org/thoughtcrime/securesms/loki/api/PrepareAttachmentAudioExtrasJob.kt
new file mode 100644
index 0000000000..07da933adf
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/loki/api/PrepareAttachmentAudioExtrasJob.kt
@@ -0,0 +1,167 @@
+package org.thoughtcrime.securesms.loki.api
+
+import android.media.MediaDataSource
+import android.os.Build
+import android.util.Log
+import androidx.annotation.RequiresApi
+import org.greenrobot.eventbus.EventBus
+import org.thoughtcrime.securesms.attachments.Attachment
+import org.thoughtcrime.securesms.attachments.AttachmentId
+import org.thoughtcrime.securesms.attachments.DatabaseAttachmentAudioExtras
+import org.thoughtcrime.securesms.database.DatabaseFactory
+import org.thoughtcrime.securesms.jobmanager.Data
+import org.thoughtcrime.securesms.jobmanager.Job
+import org.thoughtcrime.securesms.jobs.BaseJob
+import org.thoughtcrime.securesms.loki.utilities.audio.DecodedAudio
+import org.thoughtcrime.securesms.mms.PartAuthority
+import java.io.InputStream
+import java.lang.IllegalStateException
+import java.util.*
+import java.util.concurrent.TimeUnit
+
+/**
+ * Decodes the audio content of the related attachment entry
+ * and caches the result with [DatabaseAttachmentAudioExtras] data.
+ *
+ * It only process attachments with "audio" mime types.
+ *
+ * Due to [DecodedAudio] implementation limitations, it only works for API 23+.
+ * For any lower targets fake data will be generated.
+ *
+ * You can subscribe to [AudioExtrasUpdatedEvent] to be notified about the successful result.
+ */
+//TODO AC: Rewrite to WorkManager API when
+// https://github.com/loki-project/session-android/pull/354 is merged.
+class PrepareAttachmentAudioExtrasJob : BaseJob {
+
+ companion object {
+ private const val TAG = "AttachAudioExtrasJob"
+
+ const val KEY = "PrepareAttachmentAudioExtrasJob"
+ const val DATA_ATTACH_ID = "attachment_id"
+
+ const val VISUAL_RMS_FRAMES = 32 // The amount of values to be computed for the visualization.
+ }
+
+ private val attachmentId: AttachmentId
+
+ constructor(attachmentId: AttachmentId) : this(Parameters.Builder()
+ .setQueue(KEY)
+ .setLifespan(TimeUnit.DAYS.toMillis(1))
+ .build(),
+ attachmentId)
+
+ private constructor(parameters: Parameters, attachmentId: AttachmentId) : super(parameters) {
+ this.attachmentId = attachmentId
+ }
+
+ override fun serialize(): Data {
+ return Data.Builder().putParcelable(DATA_ATTACH_ID, attachmentId).build();
+ }
+
+ override fun getFactoryKey(): String { return KEY
+ }
+
+ override fun onShouldRetry(e: Exception): Boolean {
+ return false
+ }
+
+ override fun onCanceled() { }
+
+ override fun onRun() {
+ Log.v(TAG, "Processing attachment: $attachmentId")
+
+ val attachDb = DatabaseFactory.getAttachmentDatabase(context)
+ val attachment = attachDb.getAttachment(attachmentId)
+
+ if (attachment == null) {
+ throw IllegalStateException("Cannot find attachment with the ID $attachmentId")
+ }
+ if (!attachment.contentType.startsWith("audio/")) {
+ throw IllegalStateException("Attachment $attachmentId is not of audio type.")
+ }
+
+ // Check if the audio extras already exist.
+ if (attachDb.getAttachmentAudioExtras(attachmentId) != null) return
+
+ fun extractAttachmentRandomSeed(attachment: Attachment): Int {
+ return when {
+ attachment.digest != null -> attachment.digest!!.sum()
+ attachment.fileName != null -> attachment.fileName.hashCode()
+ else -> attachment.hashCode()
+ }
+ }
+
+ fun generateFakeRms(seed: Int, frames: Int = VISUAL_RMS_FRAMES): ByteArray {
+ return ByteArray(frames).apply { Random(seed.toLong()).nextBytes(this) }
+ }
+
+ var rmsValues: ByteArray
+ var totalDurationMs: Long = DatabaseAttachmentAudioExtras.DURATION_UNDEFINED
+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
+ // Due to API version incompatibility, we just display some random waveform for older API.
+ rmsValues = generateFakeRms(extractAttachmentRandomSeed(attachment))
+ } else {
+ try {
+ @Suppress("BlockingMethodInNonBlockingContext")
+ val decodedAudio = PartAuthority.getAttachmentStream(context, attachment.dataUri!!).use {
+ DecodedAudio.create(InputStreamMediaDataSource(it))
+ }
+ rmsValues = decodedAudio.calculateRms(VISUAL_RMS_FRAMES)
+ totalDurationMs = (decodedAudio.totalDuration / 1000.0).toLong()
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to decode sample values for the audio attachment \"${attachment.fileName}\".", e)
+ rmsValues = generateFakeRms(extractAttachmentRandomSeed(attachment))
+ }
+ }
+
+ attachDb.setAttachmentAudioExtras(DatabaseAttachmentAudioExtras(
+ attachmentId,
+ rmsValues,
+ totalDurationMs
+ ))
+
+ EventBus.getDefault().post(AudioExtrasUpdatedEvent(attachmentId))
+ }
+
+ class Factory : Job.Factory {
+ override fun create(parameters: Parameters, data: Data): PrepareAttachmentAudioExtrasJob {
+ return PrepareAttachmentAudioExtrasJob(parameters, data.getParcelable(DATA_ATTACH_ID, AttachmentId.CREATOR))
+ }
+ }
+
+ /** Gets dispatched once the audio extras have been updated. */
+ data class AudioExtrasUpdatedEvent(val attachmentId: AttachmentId)
+
+ @RequiresApi(Build.VERSION_CODES.M)
+ private class InputStreamMediaDataSource: MediaDataSource {
+
+ private val data: ByteArray
+
+ constructor(inputStream: InputStream): super() {
+ this.data = inputStream.readBytes()
+ }
+
+ override fun readAt(position: Long, buffer: ByteArray, offset: Int, size: Int): Int {
+ val length: Int = data.size
+ if (position >= length) {
+ return -1 // -1 indicates EOF
+ }
+ var actualSize = size
+ if (position + size > length) {
+ actualSize -= (position + size - length).toInt()
+ }
+ System.arraycopy(data, position.toInt(), buffer, offset, actualSize)
+ return actualSize
+ }
+
+ override fun getSize(): Long {
+ return data.size.toLong()
+ }
+
+ override fun close() {
+ // We don't need to close the wrapped stream.
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/org/thoughtcrime/securesms/loki/api/PublicChatManager.kt b/src/org/thoughtcrime/securesms/loki/api/PublicChatManager.kt
index 8095731318..6b710c5ea4 100644
--- a/src/org/thoughtcrime/securesms/loki/api/PublicChatManager.kt
+++ b/src/org/thoughtcrime/securesms/loki/api/PublicChatManager.kt
@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.loki.api
import android.content.Context
import android.database.ContentObserver
+import android.graphics.Bitmap
import android.text.TextUtils
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.functional.bind
@@ -10,8 +11,10 @@ import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.database.DatabaseContentProviders
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.groups.GroupManager
+import org.thoughtcrime.securesms.util.BitmapUtil
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.Util
+import org.whispersystems.signalservice.loki.api.opengroups.PublicChatInfo
import org.whispersystems.signalservice.loki.api.opengroups.PublicChat
class PublicChatManager(private val context: Context) {
@@ -24,8 +27,8 @@ class PublicChatManager(private val context: Context) {
var areAllCaughtUp = true
refreshChatsAndPollers()
for ((threadID, chat) in chats) {
- val poller = pollers[threadID] ?: PublicChatPoller(context, chat)
- areAllCaughtUp = areAllCaughtUp && poller.isCaughtUp
+ val poller = pollers[threadID]
+ areAllCaughtUp = if (poller != null) areAllCaughtUp && poller.isCaughtUp else true
}
return areAllCaughtUp
}
@@ -56,7 +59,8 @@ class PublicChatManager(private val context: Context) {
}
public fun addChat(server: String, channel: Long): Promise {
- val groupChatAPI = ApplicationContext.getInstance(context).publicChatAPI ?: return Promise.ofFail(IllegalStateException("LokiPublicChatAPI is not set!"))
+ val groupChatAPI = ApplicationContext.getInstance(context).publicChatAPI
+ ?: return Promise.ofFail(IllegalStateException("LokiPublicChatAPI is not set!"))
return groupChatAPI.getAuthToken(server).bind {
groupChatAPI.getChannelInfo(channel, server)
}.map {
@@ -64,12 +68,18 @@ class PublicChatManager(private val context: Context) {
}
}
- public fun addChat(server: String, channel: Long, name: String): PublicChat {
- val chat = PublicChat(channel, server, name, true)
+ public fun addChat(server: String, channel: Long, info: PublicChatInfo): PublicChat {
+ val chat = PublicChat(channel, server, info.displayName, true)
var threadID = GroupManager.getOpenGroupThreadID(chat.id, context)
+ var profilePicture: Bitmap? = null
// Create the group if we don't have one
if (threadID < 0) {
- val result = GroupManager.createOpenGroup(chat.id, context, null, chat.displayName)
+ if (info.profilePictureURL.isNotEmpty()) {
+ val profilePictureAsByteArray = ApplicationContext.getInstance(context).publicChatAPI
+ ?.downloadOpenGroupProfilePicture(server, info.profilePictureURL)
+ profilePicture = BitmapUtil.fromByteArray(profilePictureAsByteArray)
+ }
+ val result = GroupManager.createOpenGroup(chat.id, context, profilePicture, chat.displayName)
threadID = result.threadId
}
DatabaseFactory.getLokiThreadDatabase(context).setPublicChat(chat, threadID)
diff --git a/src/org/thoughtcrime/securesms/loki/api/PublicChatPoller.kt b/src/org/thoughtcrime/securesms/loki/api/PublicChatPoller.kt
index 374f8b68c6..ca73a77b3e 100644
--- a/src/org/thoughtcrime/securesms/loki/api/PublicChatPoller.kt
+++ b/src/org/thoughtcrime/securesms/loki/api/PublicChatPoller.kt
@@ -46,7 +46,8 @@ class PublicChatPoller(private val context: Context, private val group: PublicCh
val userPrivateKey = IdentityKeyUtil.getIdentityKeyPair(context).privateKey.serialize()
val lokiAPIDatabase = DatabaseFactory.getLokiAPIDatabase(context)
val lokiUserDatabase = DatabaseFactory.getLokiUserDatabase(context)
- PublicChatAPI(userHexEncodedPublicKey, userPrivateKey, lokiAPIDatabase, lokiUserDatabase)
+ val openGroupDatabase = DatabaseFactory.getGroupDatabase(context)
+ PublicChatAPI(userHexEncodedPublicKey, userPrivateKey, lokiAPIDatabase, lokiUserDatabase, openGroupDatabase)
}()
// endregion
diff --git a/src/org/thoughtcrime/securesms/loki/database/LokiAPIDatabase.kt b/src/org/thoughtcrime/securesms/loki/database/LokiAPIDatabase.kt
index 18fae93b87..fc7c0b44e8 100644
--- a/src/org/thoughtcrime/securesms/loki/database/LokiAPIDatabase.kt
+++ b/src/org/thoughtcrime/securesms/loki/database/LokiAPIDatabase.kt
@@ -6,7 +6,6 @@ import android.util.Log
import org.thoughtcrime.securesms.database.Database
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
import org.thoughtcrime.securesms.loki.utilities.*
-import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.signalservice.loki.api.Snode
import org.whispersystems.signalservice.loki.database.LokiAPIDatabaseProtocol
import org.whispersystems.signalservice.loki.protocol.shelved.multidevice.DeviceLink
@@ -71,6 +70,10 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
// Open group public keys
private val openGroupPublicKeyTable = "open_group_public_keys"
@JvmStatic val createOpenGroupPublicKeyTableCommand = "CREATE TABLE $openGroupPublicKeyTable ($server STRING PRIMARY KEY, $publicKey INTEGER DEFAULT 0);"
+ // Open group profile picture cache
+ private val openGroupProfilePictureTable = "open_group_avatar_cache"
+ private val openGroupProfilePicture = "open_group_avatar"
+ @JvmStatic val createOpenGroupProfilePictureTableCommand = "CREATE TABLE $openGroupProfilePictureTable ($publicChatID STRING PRIMARY KEY, $openGroupProfilePicture TEXT NULLABLE DEFAULT NULL);"
// region Deprecated
private val deviceLinkCache = "loki_pairing_authorisation_cache"
@@ -114,6 +117,30 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
database.insertOrUpdate(snodePoolTable, row, "${Companion.dummyKey} = ?", wrap("dummy_key"))
}
+ override fun setOnionRequestPaths(newValue: List>) {
+ // FIXME: This approach assumes either 1 or 2 paths of length 3 each. We should do better than this.
+ val database = databaseHelper.writableDatabase
+ fun set(indexPath: String, snode: Snode) {
+ var snodeAsString = "${snode.address}-${snode.port}"
+ val keySet = snode.publicKeySet
+ if (keySet != null) {
+ snodeAsString += "-${keySet.ed25519Key}-${keySet.x25519Key}"
+ }
+ val row = wrap(mapOf( Companion.indexPath to indexPath, Companion.snode to snodeAsString ))
+ database.insertOrUpdate(onionRequestPathTable, row, "${Companion.indexPath} = ?", wrap(indexPath))
+ }
+ Log.d("Loki", "Persisting onion request paths to database.")
+ clearOnionRequestPaths()
+ if (newValue.count() < 1) { return }
+ val path0 = newValue[0]
+ if (path0.count() != 3) { return }
+ set("0-0", path0[0]); set("0-1", path0[1]); set("0-2", path0[2])
+ if (newValue.count() < 2) { return }
+ val path1 = newValue[1]
+ if (path1.count() != 3) { return }
+ set("1-0", path1[0]); set("1-1", path1[1]); set("1-2", path1[2])
+ }
+
override fun getOnionRequestPaths(): List> {
val database = databaseHelper.readableDatabase
fun get(indexPath: String): Snode? {
@@ -131,10 +158,16 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
}
}
}
- val path0Snode0 = get("0-0") ?: return listOf(); val path0Snode1 = get("0-1") ?: return listOf()
- val path0Snode2 = get("0-2") ?: return listOf(); val path1Snode0 = get("1-0") ?: return listOf()
- val path1Snode1 = get("1-1") ?: return listOf(); val path1Snode2 = get("1-2") ?: return listOf()
- return listOf( listOf( path0Snode0, path0Snode1, path0Snode2 ), listOf( path1Snode0, path1Snode1, path1Snode2 ) )
+ val result = mutableListOf>()
+ val path0Snode0 = get("0-0"); val path0Snode1 = get("0-1"); val path0Snode2 = get("0-2")
+ if (path0Snode0 != null && path0Snode1 != null && path0Snode2 != null) {
+ result.add(listOf( path0Snode0, path0Snode1, path0Snode2 ))
+ }
+ val path1Snode0 = get("1-0"); val path1Snode1 = get("1-1"); val path1Snode2 = get("1-2")
+ if (path1Snode0 != null && path1Snode1 != null && path1Snode2 != null) {
+ result.add(listOf( path1Snode0, path1Snode1, path1Snode2 ))
+ }
+ return result
}
override fun clearOnionRequestPaths() {
@@ -147,28 +180,6 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
delete("1-1"); delete("1-2")
}
- override fun setOnionRequestPaths(newValue: List>) {
- // TODO: Make this work with arbitrary paths
- if (newValue.count() != 2) { return }
- val path0 = newValue[0]
- val path1 = newValue[1]
- if (path0.count() != 3 || path1.count() != 3) { return }
- Log.d("Loki", "Persisting onion request paths to database.")
- val database = databaseHelper.writableDatabase
- fun set(indexPath: String, snode: Snode) {
- var snodeAsString = "${snode.address}-${snode.port}"
- val keySet = snode.publicKeySet
- if (keySet != null) {
- snodeAsString += "-${keySet.ed25519Key}-${keySet.x25519Key}"
- }
- val row = wrap(mapOf( Companion.indexPath to indexPath, Companion.snode to snodeAsString ))
- database.insertOrUpdate(onionRequestPathTable, row, "${Companion.indexPath} = ?", wrap(indexPath))
- }
- set("0-0", path0[0]); set("0-1", path0[1])
- set("0-2", path0[2]); set("1-0", path1[0])
- set("1-1", path1[1]); set("1-2", path1[2])
- }
-
override fun getSwarm(publicKey: String): Set? {
val database = databaseHelper.readableDatabase
return database.get(swarmTable, "${Companion.swarmPublicKey} = ?", wrap(publicKey)) { cursor ->
@@ -343,6 +354,27 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
database.insertOrUpdate(openGroupPublicKeyTable, row, "${LokiAPIDatabase.server} = ?", wrap(server))
}
+ override fun getOpenGroupProfilePictureURL(group: Long, server: String): String? {
+ val database = databaseHelper.readableDatabase
+ val index = "$server.$group"
+ return database.get(openGroupProfilePictureTable, "$publicChatID = ?", wrap(index)) { cursor ->
+ cursor.getString(openGroupProfilePicture)
+ }?.toString()
+ }
+
+ override fun setOpenGroupProfilePictureURL(group: Long, server: String, newValue: String) {
+ val database = databaseHelper.writableDatabase
+ val index = "$server.$group"
+ val row = wrap(mapOf(publicChatID to index, openGroupProfilePicture to newValue))
+ database.insertOrUpdate(openGroupProfilePictureTable, row, "$publicChatID = ?", wrap(index))
+ }
+
+ fun clearOpenGroupProfilePictureURL(group: Long, server: String): Boolean {
+ val database = databaseHelper.writableDatabase
+ val index = "$server.$group"
+ return database.delete(openGroupProfilePictureTable, "$publicChatID = ?", arrayOf(index)) > 0
+ }
+
// region Deprecated
override fun getDeviceLinks(publicKey: String): Set {
return setOf()
diff --git a/src/org/thoughtcrime/securesms/loki/utilities/audio/DecodedAudio.kt b/src/org/thoughtcrime/securesms/loki/utilities/audio/DecodedAudio.kt
new file mode 100644
index 0000000000..12c965428b
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/loki/utilities/audio/DecodedAudio.kt
@@ -0,0 +1,368 @@
+package org.thoughtcrime.securesms.loki.utilities.audio
+
+import android.media.AudioFormat
+import android.media.MediaCodec
+import android.media.MediaDataSource
+import android.media.MediaExtractor
+import android.media.MediaFormat
+import android.os.Build
+
+import androidx.annotation.RequiresApi
+
+import java.io.FileDescriptor
+import java.io.IOException
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+import java.nio.ShortBuffer
+import kotlin.jvm.Throws
+import kotlin.math.ceil
+import kotlin.math.roundToInt
+import kotlin.math.sqrt
+
+/**
+ * Decodes the audio data and provides access to its sample data.
+ * We need this to extract RMS values for waveform visualization.
+ *
+ * Use static [DecodedAudio.create] methods to instantiate a [DecodedAudio].
+ *
+ * Partially based on the old [Google's Ringdroid project]
+ * (https://github.com/google/ringdroid/blob/master/app/src/main/java/com/ringdroid/soundfile/SoundFile.java).
+ *
+ * *NOTE:* This class instance creation might be pretty slow (depends on the source audio file size).
+ * It's recommended to instantiate it in the background.
+ */
+@Suppress("MemberVisibilityCanBePrivate")
+class DecodedAudio {
+
+ companion object {
+ @JvmStatic
+ @Throws(IOException::class)
+ fun create(fd: FileDescriptor, startOffset: Long, size: Long): DecodedAudio {
+ val mediaExtractor = MediaExtractor().apply { setDataSource(fd, startOffset, size) }
+ return DecodedAudio(mediaExtractor, size)
+ }
+
+ @JvmStatic
+ @RequiresApi(api = Build.VERSION_CODES.M)
+ @Throws(IOException::class)
+ fun create(dataSource: MediaDataSource): DecodedAudio {
+ val mediaExtractor = MediaExtractor().apply { setDataSource(dataSource) }
+ return DecodedAudio(mediaExtractor, dataSource.size)
+ }
+ }
+
+ val dataSize: Long
+
+ /** Average bit rate in kbps. */
+ val avgBitRate: Int
+
+ val sampleRate: Int
+
+ /** In microseconds. */
+ val totalDuration: Long
+
+ val channels: Int
+
+ /** Total number of samples per channel in audio file. */
+ val numSamples: Int
+
+ val samples: ShortBuffer
+ get() {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
+ Build.VERSION.SDK_INT <= Build.VERSION_CODES.N_MR1
+ ) {
+ // Hack for Nougat where asReadOnlyBuffer fails to respect byte ordering.
+ // See https://code.google.com/p/android/issues/detail?id=223824
+ decodedSamples
+ } else {
+ decodedSamples.asReadOnlyBuffer()
+ }
+ }
+
+ /**
+ * Shared buffer with mDecodedBytes.
+ * Has the following format:
+ * {s1c1, s1c2, ..., s1cM, s2c1, ..., s2cM, ..., sNc1, ..., sNcM}
+ * where sicj is the ith sample of the jth channel (a sample is a signed short)
+ * M is the number of channels (e.g. 2 for stereo) and N is the number of samples per channel.
+ */
+ private val decodedSamples: ShortBuffer
+
+ @Throws(IOException::class)
+ private constructor(extractor: MediaExtractor, size: Long) {
+ dataSize = size
+
+ var mediaFormat: MediaFormat? = null
+ // Find and select the first audio track present in the file.
+ for (trackIndex in 0 until extractor.trackCount) {
+ val format = extractor.getTrackFormat(trackIndex)
+ if (format.getString(MediaFormat.KEY_MIME)!!.startsWith("audio/")) {
+ extractor.selectTrack(trackIndex)
+ mediaFormat = format
+ break
+ }
+ }
+ if (mediaFormat == null) {
+ throw IOException("No audio track found in the data source.")
+ }
+
+ channels = mediaFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
+ sampleRate = mediaFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE)
+ // On some old APIs (23) this field might be missing.
+ totalDuration = if (mediaFormat.containsKey(MediaFormat.KEY_DURATION)) {
+ mediaFormat.getLong(MediaFormat.KEY_DURATION)
+ } else {
+ -1L
+ }
+
+ // Expected total number of samples per channel.
+ val expectedNumSamples = if (totalDuration >= 0) {
+ ((totalDuration / 1000000f) * sampleRate + 0.5f).toInt()
+ } else {
+ Int.MAX_VALUE
+ }
+
+ val codec = MediaCodec.createDecoderByType(mediaFormat.getString(MediaFormat.KEY_MIME)!!)
+ codec.configure(mediaFormat, null, null, 0)
+ codec.start()
+
+ // Check if the track is in PCM 16 bit encoding.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ try {
+ val pcmEncoding = codec.outputFormat.getInteger(MediaFormat.KEY_PCM_ENCODING)
+ if (pcmEncoding != AudioFormat.ENCODING_PCM_16BIT) {
+ throw IOException("Unsupported PCM encoding code: $pcmEncoding")
+ }
+ } catch (e: NullPointerException) {
+ // If KEY_PCM_ENCODING is not specified, means it's ENCODING_PCM_16BIT.
+ }
+ }
+
+ var decodedSamplesSize: Int = 0 // size of the output buffer containing decoded samples.
+ var decodedSamples: ByteArray? = null
+ var sampleSize: Int
+ val info = MediaCodec.BufferInfo()
+ var presentationTime: Long
+ var totalSizeRead: Int = 0
+ var doneReading = false
+
+ // Set the size of the decoded samples buffer to 1MB (~6sec of a stereo stream at 44.1kHz).
+ // For longer streams, the buffer size will be increased later on, calculating a rough
+ // estimate of the total size needed to store all the samples in order to resize the buffer
+ // only once.
+ var decodedBytes: ByteBuffer = ByteBuffer.allocate(1 shl 20)
+ var firstSampleData = true
+ while (true) {
+ // read data from file and feed it to the decoder input buffers.
+ val inputBufferIndex: Int = codec.dequeueInputBuffer(100)
+ if (!doneReading && inputBufferIndex >= 0) {
+ sampleSize = extractor.readSampleData(codec.getInputBuffer(inputBufferIndex)!!, 0)
+ if (firstSampleData
+ && mediaFormat.getString(MediaFormat.KEY_MIME)!! == "audio/mp4a-latm"
+ && sampleSize == 2
+ ) {
+ // For some reasons on some devices (e.g. the Samsung S3) you should not
+ // provide the first two bytes of an AAC stream, otherwise the MediaCodec will
+ // crash. These two bytes do not contain music data but basic info on the
+ // stream (e.g. channel configuration and sampling frequency), and skipping them
+ // seems OK with other devices (MediaCodec has already been configured and
+ // already knows these parameters).
+ extractor.advance()
+ totalSizeRead += sampleSize
+ } else if (sampleSize < 0) {
+ // All samples have been read.
+ codec.queueInputBuffer(
+ inputBufferIndex, 0, 0, -1, MediaCodec.BUFFER_FLAG_END_OF_STREAM
+ )
+ doneReading = true
+ } else {
+ presentationTime = extractor.sampleTime
+ codec.queueInputBuffer(inputBufferIndex, 0, sampleSize, presentationTime, 0)
+ extractor.advance()
+ totalSizeRead += sampleSize
+ }
+ firstSampleData = false
+ }
+
+ // Get decoded stream from the decoder output buffers.
+ val outputBufferIndex: Int = codec.dequeueOutputBuffer(info, 100)
+ if (outputBufferIndex >= 0 && info.size > 0) {
+ if (decodedSamplesSize < info.size) {
+ decodedSamplesSize = info.size
+ decodedSamples = ByteArray(decodedSamplesSize)
+ }
+ val outputBuffer: ByteBuffer = codec.getOutputBuffer(outputBufferIndex)!!
+ outputBuffer.get(decodedSamples!!, 0, info.size)
+ outputBuffer.clear()
+ // Check if buffer is big enough. Resize it if it's too small.
+ if (decodedBytes.remaining() < info.size) {
+ // Getting a rough estimate of the total size, allocate 20% more, and
+ // make sure to allocate at least 5MB more than the initial size.
+ val position = decodedBytes.position()
+ var newSize = ((position * (1.0 * dataSize / totalSizeRead)) * 1.2).toInt()
+ if (newSize - position < info.size + 5 * (1 shl 20)) {
+ newSize = position + info.size + 5 * (1 shl 20)
+ }
+ var newDecodedBytes: ByteBuffer? = null
+ // Try to allocate memory. If we are OOM, try to run the garbage collector.
+ var retry = 10
+ while (retry > 0) {
+ try {
+ newDecodedBytes = ByteBuffer.allocate(newSize)
+ break
+ } catch (e: OutOfMemoryError) {
+ // setting android:largeHeap="true" in seem to help not
+ // reaching this section.
+ retry--
+ }
+ }
+ if (retry == 0) {
+ // Failed to allocate memory... Stop reading more data and finalize the
+ // instance with the data decoded so far.
+ break
+ }
+ decodedBytes.rewind()
+ newDecodedBytes!!.put(decodedBytes)
+ decodedBytes = newDecodedBytes
+ decodedBytes.position(position)
+ }
+ decodedBytes.put(decodedSamples, 0, info.size)
+ codec.releaseOutputBuffer(outputBufferIndex, false)
+ }
+
+ if ((info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0
+ || (decodedBytes.position() / (2 * channels)) >= expectedNumSamples
+ ) {
+ // We got all the decoded data from the decoder. Stop here.
+ // Theoretically dequeueOutputBuffer(info, ...) should have set info.flags to
+ // MediaCodec.BUFFER_FLAG_END_OF_STREAM. However some phones (e.g. Samsung S3)
+ // won't do that for some files (e.g. with mono AAC files), in which case subsequent
+ // calls to dequeueOutputBuffer may result in the application crashing, without
+ // even an exception being thrown... Hence the second check.
+ // (for mono AAC files, the S3 will actually double each sample, as if the stream
+ // was stereo. The resulting stream is half what it's supposed to be and with a much
+ // lower pitch.)
+ break
+ }
+ }
+ numSamples = decodedBytes.position() / (channels * 2) // One sample = 2 bytes.
+ decodedBytes.rewind()
+ decodedBytes.order(ByteOrder.LITTLE_ENDIAN)
+ this.decodedSamples = decodedBytes.asShortBuffer()
+ avgBitRate = ((dataSize * 8) * (sampleRate.toFloat() / numSamples) / 1000).toInt()
+
+ extractor.release()
+ codec.stop()
+ codec.release()
+ }
+
+ fun calculateRms(maxFrames: Int): ByteArray {
+ return calculateRms(this.samples, this.numSamples, this.channels, maxFrames)
+ }
+}
+
+/**
+ * Computes audio RMS values for the first channel only.
+ *
+ * A typical RMS calculation algorithm is:
+ * 1. Square each sample
+ * 2. Sum the squared samples
+ * 3. Divide the sum of the squared samples by the number of samples
+ * 4. Take the square root of step 3., the mean of the squared samples
+ *
+ * @param maxFrames Defines amount of output RMS frames.
+ * If number of samples per channel is less than "maxFrames",
+ * the result array will match the source sample size instead.
+ *
+ * @return normalized RMS values as a signed byte array.
+ */
+private fun calculateRms(samples: ShortBuffer, numSamples: Int, channels: Int, maxFrames: Int): ByteArray {
+ val numFrames: Int
+ val frameStep: Float
+
+ val samplesPerChannel = numSamples / channels
+ if (samplesPerChannel <= maxFrames) {
+ frameStep = 1f
+ numFrames = samplesPerChannel
+ } else {
+ frameStep = numSamples / maxFrames.toFloat()
+ numFrames = maxFrames
+ }
+
+ val rmsValues = FloatArray(numFrames)
+
+ var squaredFrameSum = 0.0
+ var currentFrameIdx = 0
+
+ fun calculateFrameRms(nextFrameIdx: Int) {
+ rmsValues[currentFrameIdx] = sqrt(squaredFrameSum.toFloat())
+
+ // Advance to the next frame.
+ squaredFrameSum = 0.0
+ currentFrameIdx = nextFrameIdx
+ }
+
+ (0 until numSamples * channels step channels).forEach { sampleIdx ->
+ val channelSampleIdx = sampleIdx / channels
+ val frameIdx = (channelSampleIdx / frameStep).toInt()
+
+ if (currentFrameIdx != frameIdx) {
+ // Calculate RMS value for the previous frame.
+ calculateFrameRms(frameIdx)
+ }
+
+ val samplesInCurrentFrame = ceil((currentFrameIdx + 1) * frameStep) - ceil(currentFrameIdx * frameStep)
+ squaredFrameSum += (samples[sampleIdx] * samples[sampleIdx]) / samplesInCurrentFrame
+ }
+ // Calculate RMS value for the last frame.
+ calculateFrameRms(-1)
+
+// smoothArray(rmsValues, 1.0f)
+ normalizeArray(rmsValues)
+
+ // Convert normalized result to a signed byte array.
+ return rmsValues.map { value -> normalizedFloatToByte(value) }.toByteArray()
+}
+
+/**
+ * Normalizes the array's values to [0..1] range.
+ */
+private fun normalizeArray(values: FloatArray) {
+ var maxValue = -Float.MAX_VALUE
+ var minValue = +Float.MAX_VALUE
+ values.forEach { value ->
+ if (value > maxValue) maxValue = value
+ if (value < minValue) minValue = value
+ }
+ val span = maxValue - minValue
+
+ if (span == 0f) {
+ values.indices.forEach { i -> values[i] = 0f }
+ return
+ }
+
+ values.indices.forEach { i -> values[i] = (values[i] - minValue) / span }
+}
+
+private fun smoothArray(values: FloatArray, neighborWeight: Float = 1f): FloatArray {
+ if (values.size < 3) return values
+
+ val result = FloatArray(values.size)
+ result[0] = values[0]
+ result[values.size - 1] == values[values.size - 1]
+ for (i in 1 until values.size - 1) {
+ result[i] = (values[i] + values[i - 1] * neighborWeight +
+ values[i + 1] * neighborWeight) / (1f + neighborWeight * 2f)
+ }
+ return result
+}
+
+/** Turns a signed byte into a [0..1] float. */
+inline fun byteToNormalizedFloat(value: Byte): Float {
+ return (value + 128f) / 255f
+}
+
+/** Turns a [0..1] float into a signed byte. */
+inline fun normalizedFloatToByte(value: Float): Byte {
+ return (255f * value - 128f).roundToInt().toByte()
+}
\ No newline at end of file
diff --git a/src/org/thoughtcrime/securesms/loki/views/MessageAudioView.kt b/src/org/thoughtcrime/securesms/loki/views/MessageAudioView.kt
new file mode 100644
index 0000000000..3cccb6f4b8
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/loki/views/MessageAudioView.kt
@@ -0,0 +1,336 @@
+package org.thoughtcrime.securesms.loki.views
+
+import android.content.Context
+import android.content.res.ColorStateList
+import android.graphics.Color
+import android.graphics.PorterDuff
+import android.graphics.drawable.AnimatedVectorDrawable
+import android.util.AttributeSet
+import android.view.View
+import android.view.View.OnTouchListener
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import android.widget.ImageView
+import android.widget.ProgressBar
+import android.widget.TextView
+import androidx.annotation.ColorInt
+import androidx.core.content.ContextCompat
+import androidx.core.graphics.ColorUtils
+import kotlinx.coroutines.*
+import network.loki.messenger.R
+import org.greenrobot.eventbus.EventBus
+import org.greenrobot.eventbus.Subscribe
+import org.greenrobot.eventbus.ThreadMode
+import org.thoughtcrime.securesms.ApplicationContext
+import org.thoughtcrime.securesms.attachments.DatabaseAttachment
+import org.thoughtcrime.securesms.audio.AudioSlidePlayer
+import org.thoughtcrime.securesms.components.AnimatingToggle
+import org.thoughtcrime.securesms.database.AttachmentDatabase
+import org.thoughtcrime.securesms.database.DatabaseFactory
+import org.thoughtcrime.securesms.events.PartProgressEvent
+import org.thoughtcrime.securesms.logging.Log
+import org.thoughtcrime.securesms.loki.api.PrepareAttachmentAudioExtrasJob
+import org.thoughtcrime.securesms.loki.utilities.getColorWithID
+import org.thoughtcrime.securesms.mms.AudioSlide
+import org.thoughtcrime.securesms.mms.SlideClickListener
+import java.io.IOException
+import java.util.*
+import java.util.concurrent.TimeUnit
+
+class MessageAudioView: FrameLayout, AudioSlidePlayer.Listener {
+
+ companion object {
+ private const val TAG = "AudioViewKt"
+ }
+
+ private val controlToggle: AnimatingToggle
+ private val container: ViewGroup
+ private val playButton: ImageView
+ private val pauseButton: ImageView
+ private val downloadButton: ImageView
+ private val downloadProgress: ProgressBar
+ private val seekBar: WaveformSeekBar
+ private val totalDuration: TextView
+
+ private var downloadListener: SlideClickListener? = null
+ private var audioSlidePlayer: AudioSlidePlayer? = null
+
+ /** Background coroutine scope that is available when the view is attached to a window. */
+ private var asyncCoroutineScope: CoroutineScope? = null
+
+ private val loadingAnimation: SeekBarLoadingAnimation
+
+ constructor(context: Context): this(context, null)
+
+ constructor(context: Context, attrs: AttributeSet?): this(context, attrs, 0)
+
+ constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int): super(context, attrs, defStyleAttr) {
+ View.inflate(context, R.layout.message_audio_view, this)
+ container = findViewById(R.id.audio_widget_container)
+ controlToggle = findViewById(R.id.control_toggle)
+ playButton = findViewById(R.id.play)
+ pauseButton = findViewById(R.id.pause)
+ downloadButton = findViewById(R.id.download)
+ downloadProgress = findViewById(R.id.download_progress)
+ seekBar = findViewById(R.id.seek)
+ totalDuration = findViewById(R.id.total_duration)
+
+ playButton.setOnClickListener {
+ try {
+ Log.d(TAG, "playbutton onClick")
+ if (audioSlidePlayer != null) {
+ togglePlayToPause()
+
+ // Restart the playback if progress bar is nearly at the end.
+ val progress = if (seekBar.progress < 0.99f) seekBar.progress.toDouble() else 0.0
+
+ audioSlidePlayer!!.play(progress)
+ }
+ } catch (e: IOException) {
+ Log.w(TAG, e)
+ }
+ }
+ pauseButton.setOnClickListener {
+ Log.d(TAG, "pausebutton onClick")
+ if (audioSlidePlayer != null) {
+ togglePauseToPlay()
+ audioSlidePlayer!!.stop()
+ }
+ }
+ seekBar.isEnabled = false
+ seekBar.progressChangeListener = object : WaveformSeekBar.ProgressChangeListener {
+ override fun onProgressChanged(waveformSeekBar: WaveformSeekBar, progress: Float, fromUser: Boolean) {
+ if (fromUser && audioSlidePlayer != null) {
+ synchronized(audioSlidePlayer!!) {
+ audioSlidePlayer!!.seekTo(progress.toDouble())
+ }
+ }
+ }
+ }
+
+ playButton.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.play_icon))
+ pauseButton.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.pause_icon))
+ playButton.background = ContextCompat.getDrawable(context, R.drawable.ic_circle_fill_white_48dp)
+ pauseButton.background = ContextCompat.getDrawable(context, R.drawable.ic_circle_fill_white_48dp)
+
+ if (attrs != null) {
+ val typedArray = context.theme.obtainStyledAttributes(attrs, R.styleable.MessageAudioView, 0, 0)
+ setTint(typedArray.getColor(R.styleable.MessageAudioView_foregroundTintColor, Color.WHITE),
+ typedArray.getColor(R.styleable.MessageAudioView_waveformFillColor, Color.WHITE),
+ typedArray.getColor(R.styleable.MessageAudioView_waveformBackgroundColor, Color.WHITE))
+ container.setBackgroundColor(typedArray.getColor(R.styleable.MessageAudioView_widgetBackground, Color.TRANSPARENT))
+ typedArray.recycle()
+ }
+
+ loadingAnimation = SeekBarLoadingAnimation(this, seekBar)
+ loadingAnimation.start()
+ }
+
+ override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
+ if (!EventBus.getDefault().isRegistered(this)) EventBus.getDefault().register(this)
+
+ asyncCoroutineScope = CoroutineScope(Job() + Dispatchers.IO)
+ }
+
+ override fun onDetachedFromWindow() {
+ super.onDetachedFromWindow()
+ EventBus.getDefault().unregister(this)
+
+ // Cancel all the background operations.
+ asyncCoroutineScope!!.cancel()
+ asyncCoroutineScope = null
+ }
+
+ fun setAudio(audio: AudioSlide, showControls: Boolean) {
+ when {
+ showControls && audio.isPendingDownload -> {
+ controlToggle.displayQuick(downloadButton)
+ seekBar.isEnabled = false
+ downloadButton.setOnClickListener { v -> downloadListener?.onClick(v, audio) }
+ if (downloadProgress.isIndeterminate) {
+ downloadProgress.isIndeterminate = false
+ downloadProgress.progress = 0
+ }
+ }
+ (showControls && audio.transferState == AttachmentDatabase.TRANSFER_PROGRESS_STARTED) -> {
+ controlToggle.displayQuick(downloadProgress)
+ seekBar.isEnabled = false
+ downloadProgress.isIndeterminate = true
+ }
+ else -> {
+ controlToggle.displayQuick(playButton)
+ seekBar.isEnabled = true
+ if (downloadProgress.isIndeterminate) {
+ downloadProgress.isIndeterminate = false
+ downloadProgress.progress = 100
+ }
+
+ // Post to make sure it executes only when the view is attached to a window.
+ post(::updateFromAttachmentAudioExtras)
+ }
+ }
+ audioSlidePlayer = AudioSlidePlayer.createFor(context, audio, this)
+ }
+
+ fun cleanup() {
+ if (audioSlidePlayer != null && pauseButton.visibility == View.VISIBLE) {
+ audioSlidePlayer!!.stop()
+ }
+ }
+
+ fun setDownloadClickListener(listener: SlideClickListener?) {
+ downloadListener = listener
+ }
+
+ fun setTint(@ColorInt foregroundTint: Int, @ColorInt waveformFill: Int, @ColorInt waveformBackground: Int) {
+ playButton.backgroundTintList = ColorStateList.valueOf(resources.getColorWithID(R.color.white, context.theme))
+ playButton.imageTintList = ColorStateList.valueOf(resources.getColorWithID(R.color.black, context.theme))
+ pauseButton.backgroundTintList = ColorStateList.valueOf(resources.getColorWithID(R.color.white, context.theme))
+ pauseButton.imageTintList = ColorStateList.valueOf(resources.getColorWithID(R.color.black, context.theme))
+
+ downloadButton.setColorFilter(foregroundTint, PorterDuff.Mode.SRC_IN)
+
+ downloadProgress.backgroundTintList = ColorStateList.valueOf(resources.getColorWithID(R.color.white, context.theme))
+ downloadProgress.progressTintList = ColorStateList.valueOf(resources.getColorWithID(R.color.black, context.theme))
+ downloadProgress.indeterminateTintList = ColorStateList.valueOf(resources.getColorWithID(R.color.black, context.theme))
+
+ totalDuration.setTextColor(foregroundTint)
+
+ seekBar.barProgressColor = waveformFill
+ seekBar.barBackgroundColor = waveformBackground
+ }
+
+ override fun onPlayerStart(player: AudioSlidePlayer) {
+ if (pauseButton.visibility != View.VISIBLE) {
+ togglePlayToPause()
+ }
+ }
+
+ override fun onPlayerStop(player: AudioSlidePlayer) {
+ if (playButton.visibility != View.VISIBLE) {
+ togglePauseToPlay()
+ }
+ }
+
+ override fun onPlayerProgress(player: AudioSlidePlayer, progress: Double, millis: Long) {
+ seekBar.progress = progress.toFloat()
+ }
+
+ override fun setFocusable(focusable: Boolean) {
+ super.setFocusable(focusable)
+ playButton.isFocusable = focusable
+ pauseButton.isFocusable = focusable
+ seekBar.isFocusable = focusable
+ seekBar.isFocusableInTouchMode = focusable
+ downloadButton.isFocusable = focusable
+ }
+
+ override fun setClickable(clickable: Boolean) {
+ super.setClickable(clickable)
+ playButton.isClickable = clickable
+ pauseButton.isClickable = clickable
+ seekBar.isClickable = clickable
+ seekBar.setOnTouchListener(if (clickable) null else
+ OnTouchListener { _, _ -> return@OnTouchListener true }) // Suppress touch events.
+ downloadButton.isClickable = clickable
+ }
+
+ override fun setEnabled(enabled: Boolean) {
+ super.setEnabled(enabled)
+ playButton.isEnabled = enabled
+ pauseButton.isEnabled = enabled
+ downloadButton.isEnabled = enabled
+ }
+
+ private fun togglePlayToPause() {
+ controlToggle.displayQuick(pauseButton)
+ val playToPauseDrawable = ContextCompat.getDrawable(context, R.drawable.play_to_pause_animation) as AnimatedVectorDrawable
+ pauseButton.setImageDrawable(playToPauseDrawable)
+ playToPauseDrawable.start()
+ }
+
+ private fun togglePauseToPlay() {
+ controlToggle.displayQuick(playButton)
+ val pauseToPlayDrawable = ContextCompat.getDrawable(context, R.drawable.pause_to_play_animation) as AnimatedVectorDrawable
+ playButton.setImageDrawable(pauseToPlayDrawable)
+ pauseToPlayDrawable.start()
+ }
+
+ private fun obtainDatabaseAttachment(): DatabaseAttachment? {
+ audioSlidePlayer ?: return null
+ val attachment = audioSlidePlayer!!.audioSlide.asAttachment()
+ return if (attachment is DatabaseAttachment) attachment else null
+ }
+
+ private fun updateFromAttachmentAudioExtras() {
+ val attachment = obtainDatabaseAttachment() ?: return
+
+ val audioExtras = DatabaseFactory.getAttachmentDatabase(context)
+ .getAttachmentAudioExtras(attachment.attachmentId)
+
+ // Schedule a job request if no audio extras were generated yet.
+ if (audioExtras == null) {
+ ApplicationContext.getInstance(context).jobManager
+ .add(PrepareAttachmentAudioExtrasJob(attachment.attachmentId))
+ return
+ }
+
+ loadingAnimation.stop()
+ seekBar.sampleData = audioExtras.visualSamples
+
+ if (audioExtras.durationMs > 0) {
+ totalDuration.visibility = View.VISIBLE
+ totalDuration.text = String.format("%02d:%02d",
+ TimeUnit.MILLISECONDS.toMinutes(audioExtras.durationMs),
+ TimeUnit.MILLISECONDS.toSeconds(audioExtras.durationMs))
+ }
+ }
+
+ @Subscribe(sticky = true, threadMode = ThreadMode.MAIN)
+ fun onEvent(event: PartProgressEvent) {
+ if (audioSlidePlayer != null && event.attachment == audioSlidePlayer!!.audioSlide.asAttachment()) {
+ val progress = ((event.progress.toFloat() / event.total) * 100f).toInt()
+ downloadProgress.progress = progress
+ }
+ }
+
+ @Subscribe(threadMode = ThreadMode.MAIN)
+ fun onEvent(event: PrepareAttachmentAudioExtrasJob.AudioExtrasUpdatedEvent) {
+ if (event.attachmentId == obtainDatabaseAttachment()?.attachmentId) {
+ updateFromAttachmentAudioExtras()
+ }
+ }
+
+ private class SeekBarLoadingAnimation(
+ private val hostView: View,
+ private val seekBar: WaveformSeekBar): Runnable {
+
+ private var active = false
+
+ companion object {
+ private const val UPDATE_PERIOD = 250L // In milliseconds.
+ private val random = Random()
+ }
+
+ fun start() {
+ stop()
+ active = true
+ hostView.postDelayed(this, UPDATE_PERIOD)
+ }
+
+ fun stop() {
+ active = false
+ hostView.removeCallbacks(this)
+ }
+
+ override fun run() {
+ if (!active) return
+
+ // Generate a random samples with values up to the 50% of the maximum value.
+ seekBar.sampleData = ByteArray(PrepareAttachmentAudioExtrasJob.VISUAL_RMS_FRAMES)
+ { (random.nextInt(127) - 64).toByte() }
+ hostView.postDelayed(this, UPDATE_PERIOD)
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/org/thoughtcrime/securesms/loki/views/PathStatusView.kt b/src/org/thoughtcrime/securesms/loki/views/PathStatusView.kt
index e65630c144..77cfbe2505 100644
--- a/src/org/thoughtcrime/securesms/loki/views/PathStatusView.kt
+++ b/src/org/thoughtcrime/securesms/loki/views/PathStatusView.kt
@@ -85,7 +85,7 @@ class PathStatusView : View {
private fun handlePathsBuiltEvent() { update() }
private fun update() {
- if (OnionRequestAPI.paths.count() >= OnionRequestAPI.pathCount) {
+ if (OnionRequestAPI.paths.isNotEmpty()) {
setBackgroundResource(R.drawable.accent_dot)
mainColor = resources.getColorWithID(R.color.accent, context.theme)
sessionShadowColor = resources.getColorWithID(R.color.accent, context.theme)
diff --git a/src/org/thoughtcrime/securesms/loki/views/ProfilePictureView.kt b/src/org/thoughtcrime/securesms/loki/views/ProfilePictureView.kt
index 311739d44d..85105609c8 100644
--- a/src/org/thoughtcrime/securesms/loki/views/ProfilePictureView.kt
+++ b/src/org/thoughtcrime/securesms/loki/views/ProfilePictureView.kt
@@ -68,32 +68,30 @@ class ProfilePictureView : RelativeLayout {
return result ?: publicKey
}
}
- if (recipient.isGroupRecipient) {
- if ("Session Public Chat" == recipient.name) {
- publicKey = ""
- displayName = ""
- additionalPublicKey = null
- isRSSFeed = true
- } else {
- val users = MentionsManager.shared.userPublicKeyCache[threadID]?.toMutableList() ?: mutableListOf()
- users.remove(TextSecurePreferences.getLocalNumber(context))
- val masterPublicKey = TextSecurePreferences.getMasterHexEncodedPublicKey(context)
- if (masterPublicKey != null) {
- users.remove(masterPublicKey)
- }
- val randomUsers = users.sorted().toMutableList() // Sort to provide a level of stability
- if (users.count() == 1) {
- val userPublicKey = TextSecurePreferences.getLocalNumber(context)
- randomUsers.add(0, userPublicKey) // Ensure the current user is at the back visually
- }
- val pk = randomUsers.getOrNull(0) ?: ""
- publicKey = pk
- displayName = getUserDisplayName(pk)
- val apk = randomUsers.getOrNull(1) ?: ""
- additionalPublicKey = apk
- additionalDisplayName = getUserDisplayName(apk)
- isRSSFeed = recipient.name == "Loki News" || recipient.name == "Session Updates"
+ fun isOpenGroupWithProfilePicture(recipient: Recipient): Boolean {
+ return recipient.isOpenGroupRecipient && recipient.groupAvatarId != null
+ }
+ if (recipient.isGroupRecipient && !isOpenGroupWithProfilePicture(recipient)) {
+ val users = MentionsManager.shared.userPublicKeyCache[threadID]?.toMutableList() ?: mutableListOf()
+ users.remove(TextSecurePreferences.getLocalNumber(context))
+ val masterPublicKey = TextSecurePreferences.getMasterHexEncodedPublicKey(context)
+ if (masterPublicKey != null) {
+ users.remove(masterPublicKey)
}
+ val randomUsers = users.sorted().toMutableList() // Sort to provide a level of stability
+ if (users.count() == 1) {
+ val userPublicKey = TextSecurePreferences.getLocalNumber(context)
+ randomUsers.add(0, userPublicKey) // Ensure the current user is at the back visually
+ }
+ val pk = randomUsers.getOrNull(0) ?: ""
+ publicKey = pk
+ displayName = getUserDisplayName(pk)
+ val apk = randomUsers.getOrNull(1) ?: ""
+ additionalPublicKey = apk
+ additionalDisplayName = getUserDisplayName(apk)
+ isRSSFeed = recipient.name == "Loki News" ||
+ recipient.name == "Session Updates" ||
+ recipient.name == "Session Public Chat"
} else {
publicKey = recipient.address.toString()
displayName = getUserDisplayName(publicKey)
diff --git a/src/org/thoughtcrime/securesms/loki/views/WaveformSeekBar.kt b/src/org/thoughtcrime/securesms/loki/views/WaveformSeekBar.kt
new file mode 100644
index 0000000000..56ddb0f3c5
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/loki/views/WaveformSeekBar.kt
@@ -0,0 +1,315 @@
+package org.thoughtcrime.securesms.loki.views
+
+import android.animation.ValueAnimator
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.graphics.RectF
+import android.util.AttributeSet
+import android.util.TypedValue
+import android.view.MotionEvent
+import android.view.View
+import android.view.ViewConfiguration
+import android.view.animation.DecelerateInterpolator
+import androidx.core.math.MathUtils
+import network.loki.messenger.R
+import org.thoughtcrime.securesms.loki.utilities.audio.byteToNormalizedFloat
+import kotlin.math.abs
+import kotlin.math.max
+import kotlin.math.min
+import kotlin.math.roundToInt
+
+class WaveformSeekBar : View {
+
+ companion object {
+ @JvmStatic
+ fun dp(context: Context, dp: Float): Float {
+ return TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP,
+ dp,
+ context.resources.displayMetrics
+ )
+ }
+ }
+
+ private val sampleDataHolder = SampleDataHolder(::invalidate)
+ /** An array of signed byte values representing the audio signal. */
+ var sampleData: ByteArray?
+ get() {
+ return sampleDataHolder.getSamples()
+ }
+ set(value) {
+ sampleDataHolder.setSamples(value)
+ invalidate()
+ }
+
+ /** Indicates whether the user is currently interacting with the view and performing a seeking gesture. */
+ private var userSeeking = false
+ private var _progress: Float = 0f
+ /** In [0..1] range. */
+ var progress: Float
+ set(value) {
+ // Do not let to modify the progress value from the outside
+ // when the user is currently interacting with the view.
+ if (userSeeking) return
+
+ _progress = value
+ invalidate()
+ progressChangeListener?.onProgressChanged(this, _progress, false)
+ }
+ get() {
+ return _progress
+ }
+
+ var barBackgroundColor: Int = Color.LTGRAY
+ set(value) {
+ field = value
+ invalidate()
+ }
+
+ var barProgressColor: Int = Color.WHITE
+ set(value) {
+ field = value
+ invalidate()
+ }
+
+ var barGap: Float = dp(context, 2f)
+ set(value) {
+ field = value
+ invalidate()
+ }
+
+ var barWidth: Float = dp(context, 5f)
+ set(value) {
+ field = value
+ invalidate()
+ }
+
+ var barMinHeight: Float = barWidth
+ set(value) {
+ field = value
+ invalidate()
+ }
+
+ var barCornerRadius: Float = dp(context, 2.5f)
+ set(value) {
+ field = value
+ invalidate()
+ }
+
+ var barGravity: WaveGravity = WaveGravity.CENTER
+ set(value) {
+ field = value
+ invalidate()
+ }
+
+ var progressChangeListener: ProgressChangeListener? = null
+
+ private val barPaint = Paint(Paint.ANTI_ALIAS_FLAG)
+ private val barRect = RectF()
+
+ private var canvasWidth = 0
+ private var canvasHeight = 0
+
+ private var touchDownX = 0f
+ private var touchDownProgress: Float = 0f
+ private var scaledTouchSlop = ViewConfiguration.get(context).scaledTouchSlop
+
+ constructor(context: Context) : this(context, null)
+
+ constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
+
+ constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int)
+ : super(context, attrs, defStyleAttr) {
+
+ val typedAttrs = context.obtainStyledAttributes(attrs, R.styleable.WaveformSeekBar)
+ barWidth = typedAttrs.getDimension(R.styleable.WaveformSeekBar_bar_width, barWidth)
+ barGap = typedAttrs.getDimension(R.styleable.WaveformSeekBar_bar_gap, barGap)
+ barCornerRadius = typedAttrs.getDimension(
+ R.styleable.WaveformSeekBar_bar_corner_radius,
+ barCornerRadius)
+ barMinHeight =
+ typedAttrs.getDimension(R.styleable.WaveformSeekBar_bar_min_height, barMinHeight)
+ barBackgroundColor = typedAttrs.getColor(
+ R.styleable.WaveformSeekBar_bar_background_color,
+ barBackgroundColor)
+ barProgressColor =
+ typedAttrs.getColor(R.styleable.WaveformSeekBar_bar_progress_color, barProgressColor)
+ progress = typedAttrs.getFloat(R.styleable.WaveformSeekBar_progress, progress)
+ barGravity = WaveGravity.fromString(
+ typedAttrs.getString(R.styleable.WaveformSeekBar_bar_gravity))
+
+ typedAttrs.recycle()
+ }
+
+ override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
+ super.onSizeChanged(w, h, oldw, oldh)
+ canvasWidth = w
+ canvasHeight = h
+ invalidate()
+ }
+
+ override fun onDraw(canvas: Canvas) {
+ super.onDraw(canvas)
+
+ val totalWidth = getAvailableWidth()
+ val barAmount = (totalWidth / (barWidth + barGap)).toInt()
+
+ var lastBarRight = paddingLeft.toFloat()
+
+ (0 until barAmount).forEach { barIdx ->
+ // Convert a signed byte to a [0..1] float.
+ val barValue = byteToNormalizedFloat(sampleDataHolder.computeBarValue(barIdx, barAmount))
+
+ val barHeight = max(barMinHeight, getAvailableHeight() * barValue)
+
+ val top: Float = when (barGravity) {
+ WaveGravity.TOP -> paddingTop.toFloat()
+ WaveGravity.CENTER -> paddingTop + getAvailableHeight() * 0.5f - barHeight * 0.5f
+ WaveGravity.BOTTOM -> canvasHeight - paddingBottom - barHeight
+ }
+
+ barRect.set(lastBarRight, top, lastBarRight + barWidth, top + barHeight)
+
+ barPaint.color = if (barRect.right <= totalWidth * progress)
+ barProgressColor else barBackgroundColor
+
+ canvas.drawRoundRect(barRect, barCornerRadius, barCornerRadius, barPaint)
+
+ lastBarRight = barRect.right + barGap
+ }
+ }
+
+ override fun onTouchEvent(event: MotionEvent): Boolean {
+ if (!isEnabled) return false
+
+ when (event.action) {
+ MotionEvent.ACTION_DOWN -> {
+ userSeeking = true
+ touchDownX = event.x
+ touchDownProgress = progress
+ updateProgress(event, false)
+ }
+ MotionEvent.ACTION_MOVE -> {
+ // Prevent any parent scrolling if the user scrolled more
+ // than scaledTouchSlop on horizontal axis.
+ if (abs(event.x - touchDownX) > scaledTouchSlop) {
+ parent.requestDisallowInterceptTouchEvent(true)
+ }
+ updateProgress(event, false)
+ }
+ MotionEvent.ACTION_UP -> {
+ userSeeking = false
+ updateProgress(event, true)
+ performClick()
+ }
+ MotionEvent.ACTION_CANCEL -> {
+ updateProgress(touchDownProgress, false)
+ userSeeking = false
+ }
+ }
+ return true
+ }
+
+ private fun updateProgress(event: MotionEvent, notify: Boolean) {
+ updateProgress(event.x / getAvailableWidth(), notify)
+ }
+
+ private fun updateProgress(progress: Float, notify: Boolean) {
+ _progress = MathUtils.clamp(progress, 0f, 1f)
+ invalidate()
+
+ if (notify) {
+ progressChangeListener?.onProgressChanged(this, _progress, true)
+ }
+ }
+
+ override fun performClick(): Boolean {
+ super.performClick()
+ return true
+ }
+
+ private fun getAvailableWidth() = canvasWidth - paddingLeft - paddingRight
+ private fun getAvailableHeight() = canvasHeight - paddingTop - paddingBottom
+
+ private class SampleDataHolder(private val invalidateDelegate: () -> Any) {
+
+ private var sampleDataFrom: ByteArray? = null
+ private var sampleDataTo: ByteArray? = null
+ private var progress = 1f // Mix between from and to values.
+
+ private var animation: ValueAnimator? = null
+
+ fun computeBarValue(barIdx: Int, barAmount: Int): Byte {
+ /** @return The array's value at the interpolated index. */
+ fun getSampleValue(sampleData: ByteArray?): Byte {
+ if (sampleData == null || sampleData.isEmpty())
+ return Byte.MIN_VALUE
+ else {
+ val sampleIdx = (barIdx * (sampleData.size / barAmount.toFloat())).toInt()
+ return sampleData[sampleIdx]
+ }
+ }
+
+ if (progress == 1f) {
+ return getSampleValue(sampleDataTo)
+ }
+
+ val fromValue = getSampleValue(sampleDataFrom)
+ val toValue = getSampleValue(sampleDataTo)
+ val rawResultValue = fromValue * (1f - progress) + toValue * progress
+ return rawResultValue.roundToInt().toByte()
+ }
+
+ fun setSamples(sampleData: ByteArray?) {
+ /** @return a mix between [sampleDataFrom] and [sampleDataTo] arrays according to the current [progress] value. */
+ fun computeNewDataFromArray(): ByteArray? {
+ if (sampleDataTo == null) return null
+ if (sampleDataFrom == null) return sampleDataTo
+
+ val sampleSize = min(sampleDataFrom!!.size, sampleDataTo!!.size)
+ return ByteArray(sampleSize) { i -> computeBarValue(i, sampleSize) }
+ }
+
+ sampleDataFrom = computeNewDataFromArray()
+ sampleDataTo = sampleData
+ progress = 0f
+
+ animation?.cancel()
+ animation = ValueAnimator.ofFloat(0f, 1f).apply {
+ addUpdateListener { animation ->
+ progress = animation.animatedValue as Float
+ invalidateDelegate()
+ }
+ interpolator = DecelerateInterpolator(3f)
+ duration = 500
+ start()
+ }
+ }
+
+ fun getSamples(): ByteArray? {
+ return sampleDataTo
+ }
+ }
+
+ enum class WaveGravity {
+ TOP,
+ CENTER,
+ BOTTOM,
+ ;
+
+ companion object {
+ @JvmStatic
+ fun fromString(gravity: String?): WaveGravity = when (gravity) {
+ "1" -> TOP
+ "2" -> CENTER
+ else -> BOTTOM
+ }
+ }
+ }
+
+ interface ProgressChangeListener {
+ fun onProgressChanged(waveformSeekBar: WaveformSeekBar, progress: Float, fromUser: Boolean)
+ }
+}
\ No newline at end of file
diff --git a/src/org/thoughtcrime/securesms/mms/AttachmentManager.java b/src/org/thoughtcrime/securesms/mms/AttachmentManager.java
index 80af560c00..a4fac15f14 100644
--- a/src/org/thoughtcrime/securesms/mms/AttachmentManager.java
+++ b/src/org/thoughtcrime/securesms/mms/AttachmentManager.java
@@ -41,7 +41,7 @@ import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.MediaPreviewActivity;
import org.thoughtcrime.securesms.TransportOption;
import org.thoughtcrime.securesms.attachments.Attachment;
-import org.thoughtcrime.securesms.components.AudioView;
+import org.thoughtcrime.securesms.loki.views.MessageAudioView;
import org.thoughtcrime.securesms.components.DocumentView;
import org.thoughtcrime.securesms.components.RemovableEditableMediaView;
import org.thoughtcrime.securesms.components.ThumbnailView;
@@ -91,7 +91,7 @@ public class AttachmentManager {
private RemovableEditableMediaView removableMediaView;
private ThumbnailView thumbnail;
- private AudioView audioView;
+ private MessageAudioView audioView;
private DocumentView documentView;
private SignalMapView mapView;
diff --git a/src/org/thoughtcrime/securesms/mms/Slide.java b/src/org/thoughtcrime/securesms/mms/Slide.java
index cdf5ce5456..19c00664af 100644
--- a/src/org/thoughtcrime/securesms/mms/Slide.java
+++ b/src/org/thoughtcrime/securesms/mms/Slide.java
@@ -131,7 +131,7 @@ public abstract class Slide {
public @NonNull String getContentDescription() { return ""; }
- public Attachment asAttachment() {
+ public @NonNull Attachment asAttachment() {
return attachment;
}
diff --git a/src/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java b/src/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java
index 7fd5b3517c..7237cf9795 100644
--- a/src/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java
+++ b/src/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java
@@ -84,13 +84,17 @@ public class SingleRecipientNotificationBuilder extends AbstractNotificationBuil
ContactPhoto contactPhoto = recipient.getContactPhoto();
if (contactPhoto != null) {
try {
- setLargeIcon(GlideApp.with(context.getApplicationContext())
- .load(contactPhoto)
- .diskCacheStrategy(DiskCacheStrategy.ALL)
- .circleCrop()
- .submit(context.getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_width),
- context.getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_height))
- .get());
+ // AC: For some reason, if not use ".asBitmap()" method, the returned BitmapDrawable
+ // wraps a recycled bitmap and leads to a crash.
+ Bitmap iconBitmap = GlideApp.with(context.getApplicationContext())
+ .asBitmap()
+ .load(contactPhoto)
+ .diskCacheStrategy(DiskCacheStrategy.ALL)
+ .circleCrop()
+ .submit(context.getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_width),
+ context.getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_height))
+ .get();
+ setLargeIcon(iconBitmap);
} catch (InterruptedException | ExecutionException e) {
Log.w(TAG, e);
setLargeIcon(getPlaceholderDrawable(context, recipient));
diff --git a/src/org/thoughtcrime/securesms/recipients/Recipient.java b/src/org/thoughtcrime/securesms/recipients/Recipient.java
index 530aa6ecb1..24c44b19ac 100644
--- a/src/org/thoughtcrime/securesms/recipients/Recipient.java
+++ b/src/org/thoughtcrime/securesms/recipients/Recipient.java
@@ -419,6 +419,10 @@ public class Recipient implements RecipientModifiedListener {
return address.isGroup();
}
+ public boolean isOpenGroupRecipient() {
+ return address.isOpenGroup();
+ }
+
public boolean isMmsGroupRecipient() {
return address.isMmsGroup();
}
@@ -505,6 +509,11 @@ public class Recipient implements RecipientModifiedListener {
if (notify) notifyListeners();
}
+ @Nullable
+ public synchronized Long getGroupAvatarId() {
+ return groupAvatarId;
+ }
+
public synchronized @Nullable Uri getMessageRingtone() {
if (messageRingtone != null && messageRingtone.getScheme() != null && messageRingtone.getScheme().startsWith("file")) {
return null;
diff --git a/src/org/thoughtcrime/securesms/util/ParcelableUtil.kt b/src/org/thoughtcrime/securesms/util/ParcelableUtil.kt
new file mode 100644
index 0000000000..2756500b22
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/util/ParcelableUtil.kt
@@ -0,0 +1,32 @@
+package org.thoughtcrime.securesms.util
+
+import android.os.Parcel
+
+import android.os.Parcelable
+
+object ParcelableUtil {
+ @JvmStatic
+ fun marshall(parcelable: Parcelable): ByteArray {
+ val parcel = Parcel.obtain()
+ parcelable.writeToParcel(parcel, 0)
+ val bytes = parcel.marshall()
+ parcel.recycle()
+ return bytes
+ }
+
+ @JvmStatic
+ fun unmarshall(bytes: ByteArray): Parcel {
+ val parcel = Parcel.obtain()
+ parcel.unmarshall(bytes, 0, bytes.size)
+ parcel.setDataPosition(0) // This is extremely important!
+ return parcel
+ }
+
+ @JvmStatic
+ fun unmarshall(bytes: ByteArray, creator: Parcelable.Creator): T {
+ val parcel: Parcel = ParcelableUtil.unmarshall(bytes)
+ val result = creator.createFromParcel(parcel)
+ parcel.recycle()
+ return result
+ }
+}
\ No newline at end of file