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