kopia lustrzana https://github.com/ryukoposting/Signal-Android
Implement drafts for voice notes.
rodzic
2d7c043398
commit
5826b0c068
|
@ -11,11 +11,11 @@ import androidx.annotation.NonNull;
|
|||
import org.signal.core.util.ThreadUtil;
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteDraft;
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider;
|
||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SettableFuture;
|
||||
import org.whispersystems.libsignal.util.Pair;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
|
@ -51,7 +51,7 @@ public class AudioRecorder {
|
|||
captureUri = BlobProvider.getInstance()
|
||||
.forData(new ParcelFileDescriptor.AutoCloseInputStream(fds[0]), 0)
|
||||
.withMimeType(MediaUtil.AUDIO_AAC)
|
||||
.createForSingleSessionOnDiskAsync(context, () -> Log.i(TAG, "Write successful."), e -> Log.w(TAG, "Error during recording", e));
|
||||
.createForDraftAttachmentAsync(context, () -> Log.i(TAG, "Write successful."), e -> Log.w(TAG, "Error during recording", e));
|
||||
audioCodec = new AudioCodec();
|
||||
|
||||
audioCodec.start(new ParcelFileDescriptor.AutoCloseOutputStream(fds[1]));
|
||||
|
@ -61,10 +61,10 @@ public class AudioRecorder {
|
|||
});
|
||||
}
|
||||
|
||||
public @NonNull ListenableFuture<Pair<Uri, Long>> stopRecording() {
|
||||
public @NonNull ListenableFuture<VoiceNoteDraft> stopRecording() {
|
||||
Log.i(TAG, "stopRecording()");
|
||||
|
||||
final SettableFuture<Pair<Uri, Long>> future = new SettableFuture<>();
|
||||
final SettableFuture<VoiceNoteDraft> future = new SettableFuture<>();
|
||||
|
||||
executor.execute(() -> {
|
||||
if (audioCodec == null) {
|
||||
|
@ -76,7 +76,7 @@ public class AudioRecorder {
|
|||
|
||||
try {
|
||||
long size = MediaUtil.getMediaSize(context, captureUri);
|
||||
sendToFuture(future, new Pair<>(captureUri, size));
|
||||
sendToFuture(future, new VoiceNoteDraft(captureUri, size));
|
||||
} catch (IOException ioe) {
|
||||
Log.w(TAG, ioe);
|
||||
sendToFuture(future, ioe);
|
||||
|
|
|
@ -65,12 +65,6 @@ public final class AudioWaveForm {
|
|||
return;
|
||||
}
|
||||
|
||||
if (!(attachment instanceof DatabaseAttachment)) {
|
||||
Log.i(TAG, "Not yet in database");
|
||||
ThreadUtil.runOnMain(onFailure);
|
||||
return;
|
||||
}
|
||||
|
||||
String cacheKey = uri.toString();
|
||||
AudioFileInfo cached = WAVE_FORM_CACHE.get(cacheKey);
|
||||
if (cached != null) {
|
||||
|
@ -104,26 +98,46 @@ public final class AudioWaveForm {
|
|||
}
|
||||
}
|
||||
|
||||
try {
|
||||
AttachmentDatabase attachmentDatabase = DatabaseFactory.getAttachmentDatabase(context);
|
||||
DatabaseAttachment dbAttachment = (DatabaseAttachment) attachment;
|
||||
long startTime = System.currentTimeMillis();
|
||||
if (attachment instanceof DatabaseAttachment) {
|
||||
try {
|
||||
AttachmentDatabase attachmentDatabase = DatabaseFactory.getAttachmentDatabase(context);
|
||||
DatabaseAttachment dbAttachment = (DatabaseAttachment) attachment;
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
attachmentDatabase.writeAudioHash(dbAttachment.getAttachmentId(), AudioWaveFormData.getDefaultInstance());
|
||||
attachmentDatabase.writeAudioHash(dbAttachment.getAttachmentId(), AudioWaveFormData.getDefaultInstance());
|
||||
|
||||
Log.i(TAG, String.format("Starting wave form generation (%s)", cacheKey));
|
||||
Log.i(TAG, String.format("Starting wave form generation (%s)", cacheKey));
|
||||
|
||||
AudioFileInfo fileInfo = generateWaveForm(uri);
|
||||
AudioFileInfo fileInfo = generateWaveForm(uri);
|
||||
|
||||
Log.i(TAG, String.format(Locale.US, "Audio wave form generation time %d ms (%s)", System.currentTimeMillis() - startTime, cacheKey));
|
||||
Log.i(TAG, String.format(Locale.US, "Audio wave form generation time %d ms (%s)", System.currentTimeMillis() - startTime, cacheKey));
|
||||
|
||||
attachmentDatabase.writeAudioHash(dbAttachment.getAttachmentId(), fileInfo.toDatabaseProtobuf());
|
||||
attachmentDatabase.writeAudioHash(dbAttachment.getAttachmentId(), fileInfo.toDatabaseProtobuf());
|
||||
|
||||
WAVE_FORM_CACHE.put(cacheKey, fileInfo);
|
||||
ThreadUtil.runOnMain(() -> onSuccess.accept(fileInfo));
|
||||
} catch (Throwable e) {
|
||||
Log.w(TAG, "Failed to create audio wave form for " + cacheKey, e);
|
||||
ThreadUtil.runOnMain(onFailure);
|
||||
WAVE_FORM_CACHE.put(cacheKey, fileInfo);
|
||||
ThreadUtil.runOnMain(() -> onSuccess.accept(fileInfo));
|
||||
} catch (Throwable e) {
|
||||
Log.w(TAG, "Failed to create audio wave form for " + cacheKey, e);
|
||||
ThreadUtil.runOnMain(onFailure);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
Log.i(TAG, "Not in database and not cached. Generating wave form on-the-fly.");
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
Log.i(TAG, String.format("Starting wave form generation (%s)", cacheKey));
|
||||
|
||||
AudioFileInfo fileInfo = generateWaveForm(uri);
|
||||
|
||||
Log.i(TAG, String.format(Locale.US, "Audio wave form generation time %d ms (%s)", System.currentTimeMillis() - startTime, cacheKey));
|
||||
|
||||
WAVE_FORM_CACHE.put(cacheKey, fileInfo);
|
||||
ThreadUtil.runOnMain(() -> onSuccess.accept(fileInfo));
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "Failed to create audio wave form for " + cacheKey, e);
|
||||
ThreadUtil.runOnMain(onFailure);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -45,6 +45,10 @@ public final class AudioView extends FrameLayout {
|
|||
|
||||
private static final String TAG = Log.tag(AudioView.class);
|
||||
|
||||
private static final int MODE_NORMAL = 0;
|
||||
private static final int MODE_SMALL = 1;
|
||||
private static final int MODE_DRAFT = 2;
|
||||
|
||||
private static final int FORWARDS = 1;
|
||||
private static final int REVERSE = -1;
|
||||
|
||||
|
@ -87,10 +91,23 @@ public final class AudioView extends FrameLayout {
|
|||
try {
|
||||
typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.AudioView, 0, 0);
|
||||
|
||||
smallView = typedArray.getBoolean(R.styleable.AudioView_small, false);
|
||||
int mode = typedArray.getInteger(R.styleable.AudioView_audioView_mode, MODE_NORMAL);
|
||||
smallView = mode == MODE_SMALL;
|
||||
autoRewind = typedArray.getBoolean(R.styleable.AudioView_autoRewind, false);
|
||||
|
||||
inflate(context, smallView ? R.layout.audio_view_small : R.layout.audio_view, this);
|
||||
switch (mode) {
|
||||
case MODE_NORMAL:
|
||||
inflate(context, R.layout.audio_view, this);
|
||||
break;
|
||||
case MODE_SMALL:
|
||||
inflate(context, R.layout.audio_view_small, this);
|
||||
break;
|
||||
case MODE_DRAFT:
|
||||
inflate(context, R.layout.audio_view_draft, this);
|
||||
break;
|
||||
default:
|
||||
throw new IllegalStateException("Unsupported mode: " + mode);
|
||||
}
|
||||
|
||||
this.controlToggle = findViewById(R.id.control_toggle);
|
||||
this.playPauseButton = findViewById(R.id.play);
|
||||
|
@ -280,7 +297,9 @@ public final class AudioView extends FrameLayout {
|
|||
}
|
||||
|
||||
private void onSpeedChanged(@NonNull Uri uri, float speed) {
|
||||
callbacks.onSpeedChanged(speed, isTarget(uri));
|
||||
if (callbacks != null) {
|
||||
callbacks.onSpeedChanged(speed, isTarget(uri));
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isTarget(@NonNull Uri uri) {
|
||||
|
|
|
@ -24,6 +24,7 @@ import androidx.annotation.MainThread;
|
|||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.lifecycle.Observer;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
|
@ -34,7 +35,10 @@ import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
|
|||
import org.thoughtcrime.securesms.components.emoji.EmojiEventListener;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiToggle;
|
||||
import org.thoughtcrime.securesms.components.emoji.MediaKeyboard;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationStickerSuggestionAdapter;
|
||||
import org.thoughtcrime.securesms.conversation.VoiceNoteDraftView;
|
||||
import org.thoughtcrime.securesms.database.DraftDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.StickerRecord;
|
||||
import org.thoughtcrime.securesms.keyboard.KeyboardPage;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
|
@ -83,6 +87,7 @@ public class InputPanel extends LinearLayout
|
|||
private SlideToCancel slideToCancel;
|
||||
private RecordTime recordTime;
|
||||
private ValueAnimator quoteAnimator;
|
||||
private VoiceNoteDraftView voiceNoteDraftView;
|
||||
|
||||
private @Nullable Listener listener;
|
||||
private boolean emojiVisible;
|
||||
|
@ -118,6 +123,7 @@ public class InputPanel extends LinearLayout
|
|||
this.buttonToggle = findViewById(R.id.button_toggle);
|
||||
this.recordingContainer = findViewById(R.id.recording_container);
|
||||
this.recordLockCancel = findViewById(R.id.record_cancel);
|
||||
this.voiceNoteDraftView = findViewById(R.id.voice_note_draft_view);
|
||||
this.slideToCancel = new SlideToCancel(findViewById(R.id.slide_to_cancel));
|
||||
this.microphoneRecorderView = findViewById(R.id.recorder_view);
|
||||
this.microphoneRecorderView.setListener(this);
|
||||
|
@ -154,6 +160,7 @@ public class InputPanel extends LinearLayout
|
|||
this.listener = listener;
|
||||
|
||||
mediaKeyboard.setOnClickListener(v -> listener.onEmojiToggle());
|
||||
voiceNoteDraftView.setListener(listener);
|
||||
}
|
||||
|
||||
public void setMediaListener(@NonNull MediaListener listener) {
|
||||
|
@ -229,6 +236,10 @@ public class InputPanel extends LinearLayout
|
|||
return animator;
|
||||
}
|
||||
|
||||
public boolean hasSaveableContent() {
|
||||
return getQuote().isPresent() || voiceNoteDraftView.getDraft() != null;
|
||||
}
|
||||
|
||||
public Optional<QuoteModel> getQuote() {
|
||||
if (quoteView.getQuoteId() > 0 && quoteView.getVisibility() == View.VISIBLE) {
|
||||
return Optional.of(new QuoteModel(quoteView.getQuoteId(), quoteView.getAuthor().getId(), quoteView.getBody().toString(), false, quoteView.getAttachments(), quoteView.getMentions()));
|
||||
|
@ -316,7 +327,10 @@ public class InputPanel extends LinearLayout
|
|||
recordTime.display();
|
||||
slideToCancel.display();
|
||||
|
||||
if (emojiVisible) ViewUtil.fadeOut(mediaKeyboard, FADE_TIME, View.INVISIBLE);
|
||||
if (emojiVisible) {
|
||||
ViewUtil.fadeOut(mediaKeyboard, FADE_TIME, View.INVISIBLE);
|
||||
}
|
||||
|
||||
ViewUtil.fadeOut(composeText, FADE_TIME, View.INVISIBLE);
|
||||
ViewUtil.fadeOut(quickCameraToggle, FADE_TIME, View.INVISIBLE);
|
||||
ViewUtil.fadeOut(quickAudioToggle, FADE_TIME, View.INVISIBLE);
|
||||
|
@ -369,6 +383,10 @@ public class InputPanel extends LinearLayout
|
|||
this.microphoneRecorderView.cancelAction();
|
||||
}
|
||||
|
||||
public @NonNull Observer<VoiceNotePlaybackState> getPlaybackStateObserver() {
|
||||
return voiceNoteDraftView.getPlaybackStateObserver();
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
composeText.setEnabled(enabled);
|
||||
mediaKeyboard.setEnabled(enabled);
|
||||
|
@ -385,11 +403,7 @@ public class InputPanel extends LinearLayout
|
|||
future.addListener(new AssertedSuccessListener<Void>() {
|
||||
@Override
|
||||
public void onSuccess(Void result) {
|
||||
if (emojiVisible) ViewUtil.fadeIn(mediaKeyboard, FADE_TIME);
|
||||
ViewUtil.fadeIn(composeText, FADE_TIME);
|
||||
ViewUtil.fadeIn(quickCameraToggle, FADE_TIME);
|
||||
ViewUtil.fadeIn(quickAudioToggle, FADE_TIME);
|
||||
buttonToggle.animate().alpha(1).setDuration(FADE_TIME).start();
|
||||
fadeInNormalComposeViews();
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -438,7 +452,41 @@ public class InputPanel extends LinearLayout
|
|||
.show(TooltipPopup.POSITION_ABOVE);
|
||||
}
|
||||
|
||||
public interface Listener {
|
||||
public void setVoiceNoteDraft(@Nullable DraftDatabase.Draft voiceNoteDraft) {
|
||||
if (voiceNoteDraft != null) {
|
||||
voiceNoteDraftView.setDraft(voiceNoteDraft);
|
||||
voiceNoteDraftView.setVisibility(VISIBLE);
|
||||
|
||||
if (emojiVisible) {
|
||||
mediaKeyboard.setVisibility(View.INVISIBLE);
|
||||
}
|
||||
|
||||
composeText.setVisibility(View.INVISIBLE);
|
||||
quickCameraToggle.setVisibility(View.INVISIBLE);
|
||||
quickAudioToggle.setVisibility(View.INVISIBLE);
|
||||
} else {
|
||||
voiceNoteDraftView.clearDraft();
|
||||
ViewUtil.fadeOut(voiceNoteDraftView, FADE_TIME);
|
||||
fadeInNormalComposeViews();
|
||||
}
|
||||
}
|
||||
|
||||
public @Nullable DraftDatabase.Draft getVoiceNoteDraft() {
|
||||
return voiceNoteDraftView.getDraft();
|
||||
}
|
||||
|
||||
private void fadeInNormalComposeViews() {
|
||||
if (emojiVisible) {
|
||||
ViewUtil.fadeIn(mediaKeyboard, FADE_TIME);
|
||||
}
|
||||
|
||||
ViewUtil.fadeIn(composeText, FADE_TIME);
|
||||
ViewUtil.fadeIn(quickCameraToggle, FADE_TIME);
|
||||
ViewUtil.fadeIn(quickAudioToggle, FADE_TIME);
|
||||
buttonToggle.animate().alpha(1).setDuration(FADE_TIME).start();
|
||||
}
|
||||
|
||||
public interface Listener extends VoiceNoteDraftView.Listener {
|
||||
void onRecorderStarted();
|
||||
void onRecorderLocked();
|
||||
void onRecorderFinished();
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
package org.thoughtcrime.securesms.components.voice
|
||||
|
||||
import android.net.Uri
|
||||
import org.thoughtcrime.securesms.database.DraftDatabase
|
||||
import java.lang.IllegalArgumentException
|
||||
|
||||
private const val SIZE = "size"
|
||||
|
||||
class VoiceNoteDraft(
|
||||
val uri: Uri,
|
||||
val size: Long
|
||||
) {
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun fromDraft(draft: DraftDatabase.Draft): VoiceNoteDraft {
|
||||
if (draft.type != DraftDatabase.Draft.VOICE_NOTE) {
|
||||
throw IllegalArgumentException()
|
||||
}
|
||||
|
||||
val draftUri = Uri.parse(draft.value)
|
||||
|
||||
val uri: Uri = draftUri.buildUpon().clearQuery().build()
|
||||
val size: Long = draftUri.getQueryParameter("size")!!.toLong()
|
||||
|
||||
return VoiceNoteDraft(uri, size)
|
||||
}
|
||||
}
|
||||
|
||||
fun asDraft(): DraftDatabase.Draft {
|
||||
val draftUri = uri.buildUpon().appendQueryParameter(SIZE, size.toString())
|
||||
|
||||
return DraftDatabase.Draft(DraftDatabase.Draft.VOICE_NOTE, draftUri.build().toString())
|
||||
}
|
||||
}
|
|
@ -35,6 +35,7 @@ import java.util.Objects;
|
|||
*/
|
||||
public class VoiceNoteMediaController implements DefaultLifecycleObserver {
|
||||
|
||||
public static final String EXTRA_THREAD_ID = "voice.note.thread_id";
|
||||
public static final String EXTRA_MESSAGE_ID = "voice.note.message_id";
|
||||
public static final String EXTRA_PROGRESS = "voice.note.playhead";
|
||||
public static final String EXTRA_PLAY_SINGLE = "voice.note.play.single";
|
||||
|
@ -99,11 +100,15 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
|
|||
|
||||
|
||||
public void startConsecutivePlayback(@NonNull Uri audioSlideUri, long messageId, double progress) {
|
||||
startPlayback(audioSlideUri, messageId, progress, false);
|
||||
startPlayback(audioSlideUri, messageId, -1, progress, false);
|
||||
}
|
||||
|
||||
public void startSinglePlayback(@NonNull Uri audioSlideUri, long messageId, double progress) {
|
||||
startPlayback(audioSlideUri, messageId, progress, true);
|
||||
startPlayback(audioSlideUri, messageId, -1, progress, true);
|
||||
}
|
||||
|
||||
public void startSinglePlaybackForDraft(@NonNull Uri draftUri, long threadId, double progress) {
|
||||
startPlayback(draftUri, -1, threadId, progress, true);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -115,7 +120,7 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
|
|||
* @param progress The desired progress % to seek to.
|
||||
* @param singlePlayback The player will only play back the specified Uri, and not build a playlist.
|
||||
*/
|
||||
private void startPlayback(@NonNull Uri audioSlideUri, long messageId, double progress, boolean singlePlayback) {
|
||||
private void startPlayback(@NonNull Uri audioSlideUri, long messageId, long threadId, double progress, boolean singlePlayback) {
|
||||
if (isCurrentTrack(audioSlideUri)) {
|
||||
long duration = getMediaController().getMetadata().getLong(MediaMetadataCompat.METADATA_KEY_DURATION);
|
||||
|
||||
|
@ -124,6 +129,7 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
|
|||
} else {
|
||||
Bundle extras = new Bundle();
|
||||
extras.putLong(EXTRA_MESSAGE_ID, messageId);
|
||||
extras.putLong(EXTRA_THREAD_ID, threadId);
|
||||
extras.putDouble(EXTRA_PROGRESS, progress);
|
||||
extras.putBoolean(EXTRA_PLAY_SINGLE, singlePlayback);
|
||||
|
||||
|
|
|
@ -17,7 +17,6 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
|||
import org.thoughtcrime.securesms.preferences.widgets.NotificationPrivacyPreference;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.DateUtils;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
@ -39,13 +38,33 @@ class VoiceNoteMediaDescriptionCompatFactory {
|
|||
|
||||
private VoiceNoteMediaDescriptionCompatFactory() {}
|
||||
|
||||
static MediaDescriptionCompat buildMediaDescription(@NonNull Context context,
|
||||
long threadId,
|
||||
@NonNull Uri draftUri)
|
||||
{
|
||||
|
||||
Recipient threadRecipient = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(threadId);
|
||||
if (threadRecipient == null) {
|
||||
threadRecipient = Recipient.UNKNOWN;
|
||||
}
|
||||
|
||||
return buildMediaDescription(context,
|
||||
threadRecipient,
|
||||
Recipient.self(),
|
||||
Recipient.self(),
|
||||
0,
|
||||
threadId,
|
||||
-1,
|
||||
System.currentTimeMillis(),
|
||||
draftUri);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build out a MediaDescriptionCompat for a given voice note. Expects to be run
|
||||
* on a background thread.
|
||||
*
|
||||
* @param context Context.
|
||||
* @param messageRecord The MessageRecord of the given voice note.
|
||||
*
|
||||
* @return A MediaDescriptionCompat with all the details the service expects.
|
||||
*/
|
||||
@WorkerThread
|
||||
|
@ -60,15 +79,37 @@ class VoiceNoteMediaDescriptionCompatFactory {
|
|||
.getRecipientForThreadId(messageRecord.getThreadId()));
|
||||
Recipient sender = messageRecord.isOutgoing() ? Recipient.self() : messageRecord.getIndividualRecipient();
|
||||
Recipient avatarRecipient = threadRecipient.isGroup() ? threadRecipient : sender;
|
||||
Uri uri = Objects.requireNonNull(((MmsMessageRecord) messageRecord).getSlideDeck().getAudioSlide().getUri());
|
||||
|
||||
return buildMediaDescription(context,
|
||||
threadRecipient,
|
||||
avatarRecipient,
|
||||
sender,
|
||||
startingPosition,
|
||||
messageRecord.getThreadId(),
|
||||
messageRecord.getId(),
|
||||
messageRecord.getDateReceived(),
|
||||
uri);
|
||||
}
|
||||
|
||||
private static MediaDescriptionCompat buildMediaDescription(@NonNull Context context,
|
||||
@NonNull Recipient threadRecipient,
|
||||
@NonNull Recipient avatarRecipient,
|
||||
@NonNull Recipient sender,
|
||||
int startingPosition,
|
||||
long threadId,
|
||||
long messageId,
|
||||
long dateReceived,
|
||||
@NonNull Uri audioUri)
|
||||
{
|
||||
Bundle extras = new Bundle();
|
||||
extras.putString(EXTRA_THREAD_RECIPIENT_ID, threadRecipient.getId().serialize());
|
||||
extras.putString(EXTRA_AVATAR_RECIPIENT_ID, avatarRecipient.getId().serialize());
|
||||
extras.putString(EXTRA_INDIVIDUAL_RECIPIENT_ID, sender.getId().serialize());
|
||||
extras.putLong(EXTRA_MESSAGE_POSITION, startingPosition);
|
||||
extras.putLong(EXTRA_THREAD_ID, messageRecord.getThreadId());
|
||||
extras.putLong(EXTRA_THREAD_ID, threadId);
|
||||
extras.putLong(EXTRA_COLOR, threadRecipient.getChatColors().asSingleColor());
|
||||
extras.putLong(EXTRA_MESSAGE_ID, messageRecord.getId());
|
||||
extras.putLong(EXTRA_MESSAGE_ID, messageId);
|
||||
|
||||
NotificationPrivacyPreference preference = SignalStore.settings().getMessageNotificationsPrivacy();
|
||||
|
||||
|
@ -87,13 +128,11 @@ class VoiceNoteMediaDescriptionCompatFactory {
|
|||
if (preference.isDisplayContact()) {
|
||||
subtitle = context.getString(R.string.VoiceNoteMediaDescriptionCompatFactory__voice_message,
|
||||
DateUtils.formatDateWithoutDayOfWeek(Locale.getDefault(),
|
||||
messageRecord.getDateReceived()));
|
||||
dateReceived));
|
||||
}
|
||||
|
||||
Uri uri = ((MmsMessageRecord) messageRecord).getSlideDeck().getAudioSlide().getUri();
|
||||
|
||||
return new MediaDescriptionCompat.Builder()
|
||||
.setMediaUri(uri)
|
||||
.setMediaUri(audioUri)
|
||||
.setTitle(title)
|
||||
.setSubtitle(subtitle)
|
||||
.setExtras(extras)
|
||||
|
|
|
@ -23,6 +23,8 @@ import org.signal.core.util.logging.Log;
|
|||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.NoSuchMessageException;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.MessageRecordUtil;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
|
||||
|
@ -95,6 +97,7 @@ final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackP
|
|||
Log.d(TAG, "onPrepareFromUri: " + uri);
|
||||
|
||||
long messageId = extras.getLong(VoiceNoteMediaController.EXTRA_MESSAGE_ID);
|
||||
long threadId = extras.getLong(VoiceNoteMediaController.EXTRA_THREAD_ID);
|
||||
double progress = extras.getDouble(VoiceNoteMediaController.EXTRA_PROGRESS, 0);
|
||||
boolean singlePlayback = extras.getBoolean(VoiceNoteMediaController.EXTRA_PLAY_SINGLE, false);
|
||||
|
||||
|
@ -104,7 +107,11 @@ final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackP
|
|||
SimpleTask.run(EXECUTOR,
|
||||
() -> {
|
||||
if (singlePlayback) {
|
||||
return loadMediaDescriptionForSinglePlayback(messageId);
|
||||
if (messageId != -1) {
|
||||
return loadMediaDescriptionForSinglePlayback(messageId);
|
||||
} else {
|
||||
return loadMediaDescriptionForDraftPlayback(threadId, uri);
|
||||
}
|
||||
} else {
|
||||
return loadMediaDescriptionsForConsecutivePlayback(messageId);
|
||||
}
|
||||
|
@ -262,6 +269,10 @@ final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackP
|
|||
}
|
||||
}
|
||||
|
||||
private @NonNull List<MediaDescriptionCompat> loadMediaDescriptionForDraftPlayback(long threadId, @NonNull Uri draftUri) {
|
||||
return Collections.singletonList(VoiceNoteMediaDescriptionCompatFactory.buildMediaDescription(context, threadId, draftUri));
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private @NonNull List<MediaDescriptionCompat> loadMediaDescriptionsForConsecutivePlayback(long messageId) {
|
||||
try {
|
||||
|
|
|
@ -76,6 +76,8 @@ import androidx.core.content.pm.ShortcutInfoCompat;
|
|||
import androidx.core.content.pm.ShortcutManagerCompat;
|
||||
import androidx.core.graphics.drawable.DrawableCompat;
|
||||
import androidx.core.graphics.drawable.IconCompat;
|
||||
import androidx.lifecycle.Lifecycle;
|
||||
import androidx.lifecycle.Observer;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
|
||||
import com.annimon.stream.Collectors;
|
||||
|
@ -130,6 +132,9 @@ import org.thoughtcrime.securesms.components.reminder.ReminderView;
|
|||
import org.thoughtcrime.securesms.components.reminder.ServiceOutageReminder;
|
||||
import org.thoughtcrime.securesms.components.reminder.UnauthorizedReminder;
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteDraft;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState;
|
||||
import org.thoughtcrime.securesms.contacts.ContactAccessor;
|
||||
import org.thoughtcrime.securesms.contacts.ContactAccessor.ContactData;
|
||||
import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper;
|
||||
|
@ -139,6 +144,8 @@ import org.thoughtcrime.securesms.contactshare.ContactUtil;
|
|||
import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationGroupViewModel.GroupActiveState;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory;
|
||||
import org.thoughtcrime.securesms.conversation.drafts.DraftRepository;
|
||||
import org.thoughtcrime.securesms.conversation.drafts.DraftViewModel;
|
||||
import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog;
|
||||
import org.thoughtcrime.securesms.conversation.ui.groupcall.GroupCallViewModel;
|
||||
import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerViewModel;
|
||||
|
@ -402,6 +409,9 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
|||
private MentionsPickerViewModel mentionsViewModel;
|
||||
private GroupCallViewModel groupCallViewModel;
|
||||
private VoiceRecorderWakeLock voiceRecorderWakeLock;
|
||||
private DraftViewModel draftViewModel;
|
||||
private VoiceNoteMediaController voiceNoteMediaController;
|
||||
|
||||
|
||||
private LiveRecipient recipient;
|
||||
private long threadId;
|
||||
|
@ -435,7 +445,8 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
|||
return;
|
||||
}
|
||||
|
||||
voiceRecorderWakeLock = new VoiceRecorderWakeLock(this);
|
||||
voiceNoteMediaController = new VoiceNoteMediaController(this);
|
||||
voiceRecorderWakeLock = new VoiceRecorderWakeLock(this);
|
||||
|
||||
new FullscreenHelper(this).showSystemUI();
|
||||
|
||||
|
@ -462,6 +473,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
|||
initializeGroupViewModel();
|
||||
initializeMentionsViewModel();
|
||||
initializeGroupCallViewModel();
|
||||
initializeDraftViewModel();
|
||||
initializeEnabledCheck();
|
||||
initializePendingRequestsBanner();
|
||||
initializeGroupV1MigrationsBanners();
|
||||
|
@ -520,7 +532,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
|||
}
|
||||
|
||||
reactWithAnyEmojiStartPage = -1;
|
||||
if (!Util.isEmpty(composeText) || attachmentManager.isAttachmentPresent() || inputPanel.getQuote().isPresent()) {
|
||||
if (!Util.isEmpty(composeText) || attachmentManager.isAttachmentPresent() || inputPanel.hasSaveableContent()) {
|
||||
saveDraft();
|
||||
attachmentManager.clear(glideRequests, false);
|
||||
inputPanel.clearQuote();
|
||||
|
@ -627,6 +639,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
|||
@Override
|
||||
protected void onStop() {
|
||||
super.onStop();
|
||||
saveDraft();
|
||||
EventBus.getDefault().unregister(this);
|
||||
}
|
||||
|
||||
|
@ -647,7 +660,6 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
|||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
saveDraft();
|
||||
if (securityUpdateReceiver != null) unregisterReceiver(securityUpdateReceiver);
|
||||
super.onDestroy();
|
||||
}
|
||||
|
@ -1761,6 +1773,9 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
|||
SettableFuture<Boolean> quoteResult = new SettableFuture<>();
|
||||
new QuoteRestorationTask(draft.getValue(), quoteResult).execute();
|
||||
quoteResult.addListener(listener);
|
||||
case Draft.VOICE_NOTE:
|
||||
draftViewModel.setVoiceNoteDraft(recipient.getId(), draft);
|
||||
voiceNoteMediaController.getVoiceNotePlaybackState().observe(ConversationActivity.this, inputPanel.getPlaybackStateObserver());
|
||||
break;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
|
@ -2277,6 +2292,20 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
|||
groupCallViewModel.groupCallHasCapacity().observe(this, hasCapacity -> joinGroupCallButton.setText(hasCapacity ? R.string.ConversationActivity_join : R.string.ConversationActivity_full));
|
||||
}
|
||||
|
||||
public void initializeDraftViewModel() {
|
||||
draftViewModel = ViewModelProviders.of(this, new DraftViewModel.Factory(new DraftRepository(getApplicationContext()))).get(DraftViewModel.class);
|
||||
|
||||
recipient.observe(this, r -> {
|
||||
draftViewModel.onRecipientChanged(r);
|
||||
});
|
||||
|
||||
draftViewModel.getState().observe(this,
|
||||
state -> {
|
||||
inputPanel.setVoiceNoteDraft(state.getVoiceNoteDraft());
|
||||
updateToggleButtonState();
|
||||
});
|
||||
}
|
||||
|
||||
private void showGroupCallingTooltip() {
|
||||
if (Build.VERSION.SDK_INT == 19 || !SignalStore.tooltips().shouldShowGroupCallingTooltip() || callingTooltipShown) {
|
||||
return;
|
||||
|
@ -2416,6 +2445,10 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
|||
groupCallViewModel.onRecipientChange(recipient);
|
||||
}
|
||||
|
||||
if (draftViewModel != null) {
|
||||
draftViewModel.onRecipientChanged(recipient);
|
||||
}
|
||||
|
||||
if (this.threadId == -1) {
|
||||
SimpleTask.run(() -> DatabaseFactory.getThreadDatabase(this).getThreadIdIfExistsFor(recipient.getId()), threadId -> {
|
||||
if (this.threadId != threadId) {
|
||||
|
@ -2562,6 +2595,11 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
|||
drafts.add(new Draft(Draft.QUOTE, new QuoteId(quote.get().getId(), quote.get().getAuthor()).serialize()));
|
||||
}
|
||||
|
||||
DraftDatabase.Draft voiceNoteDraft = draftViewModel.getVoiceNoteDraft();
|
||||
if (voiceNoteDraft != null) {
|
||||
drafts.add(voiceNoteDraft);
|
||||
}
|
||||
|
||||
return drafts;
|
||||
}
|
||||
|
||||
|
@ -2573,13 +2611,25 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
|||
return future;
|
||||
}
|
||||
|
||||
final Drafts drafts = getDraftsForCurrentState();
|
||||
final long thisThreadId = this.threadId;
|
||||
final int thisDistributionType = this.distributionType;
|
||||
final Drafts drafts = getDraftsForCurrentState();
|
||||
final long thisThreadId = this.threadId;
|
||||
final RecipientId recipientId = this.recipient.getId();
|
||||
final int thisDistributionType = this.distributionType;
|
||||
final ListenableFuture<VoiceNoteDraft> voiceNoteDraftFuture = draftViewModel.consumeVoiceNoteDraftFuture();
|
||||
|
||||
new AsyncTask<Long, Void, Long>() {
|
||||
@Override
|
||||
protected Long doInBackground(Long... params) {
|
||||
if (voiceNoteDraftFuture != null) {
|
||||
try {
|
||||
Draft voiceNoteDraft = voiceNoteDraftFuture.get().asDraft();
|
||||
draftViewModel.setVoiceNoteDraft(recipientId, voiceNoteDraft);
|
||||
drafts.add(voiceNoteDraft);
|
||||
} catch (ExecutionException | InterruptedException e) {
|
||||
Log.w(TAG, "Could not extract voice note draft data.", e);
|
||||
}
|
||||
}
|
||||
|
||||
ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(ConversationActivity.this);
|
||||
DraftDatabase draftDatabase = DatabaseFactory.getDraftDatabase(ConversationActivity.this);
|
||||
long threadId = params[0];
|
||||
|
@ -2587,7 +2637,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
|||
if (drafts.size() > 0) {
|
||||
if (threadId == -1) threadId = threadDatabase.getThreadIdFor(getRecipient(), thisDistributionType);
|
||||
|
||||
draftDatabase.insertDrafts(threadId, drafts);
|
||||
draftDatabase.replaceDrafts(threadId, drafts);
|
||||
threadDatabase.updateSnippet(threadId, drafts.getSnippet(ConversationActivity.this),
|
||||
drafts.getUriSnippet(),
|
||||
System.currentTimeMillis(), Types.BASE_DRAFT_TYPE, true);
|
||||
|
@ -2761,6 +2811,15 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
|||
return;
|
||||
}
|
||||
|
||||
Draft voiceNote = draftViewModel.getVoiceNoteDraft();
|
||||
if (voiceNote != null) {
|
||||
AudioSlide audioSlide = AudioSlide.createFromVoiceNoteDraft(this, voiceNote);
|
||||
|
||||
sendVoiceNote(Objects.requireNonNull(audioSlide.getUri()), audioSlide.getFileSize());
|
||||
draftViewModel.clearVoiceNoteDraft();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Recipient recipient = getRecipient();
|
||||
|
||||
|
@ -2975,6 +3034,13 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
|||
return;
|
||||
}
|
||||
|
||||
if (draftViewModel.hasVoiceNoteDraft()) {
|
||||
buttonToggle.display(sendButton);
|
||||
quickAttachmentToggle.hide();
|
||||
inlineAttachmentToggle.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
if (composeText.getText().length() == 0 && !attachmentManager.isAttachmentPresent()) {
|
||||
buttonToggle.display(attachButton);
|
||||
quickAttachmentToggle.show();
|
||||
|
@ -3063,44 +3129,11 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
|||
getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED);
|
||||
|
||||
ListenableFuture<Pair<Uri, Long>> future = audioRecorder.stopRecording();
|
||||
future.addListener(new ListenableFuture.Listener<Pair<Uri, Long>>() {
|
||||
ListenableFuture<VoiceNoteDraft> future = audioRecorder.stopRecording();
|
||||
future.addListener(new ListenableFuture.Listener<VoiceNoteDraft>() {
|
||||
@Override
|
||||
public void onSuccess(final @NonNull Pair<Uri, Long> result) {
|
||||
boolean forceSms = sendButton.isManualSelection() && sendButton.getSelectedTransport().isSms();
|
||||
boolean initiating = threadId == -1;
|
||||
int subscriptionId = sendButton.getSelectedTransport().getSimSubscriptionId().or(-1);
|
||||
long expiresIn = recipient.get().getExpireMessages() * 1000L;
|
||||
AudioSlide audioSlide = new AudioSlide(ConversationActivity.this, result.first(), result.second(), MediaUtil.AUDIO_AAC, true);
|
||||
SlideDeck slideDeck = new SlideDeck();
|
||||
slideDeck.addSlide(audioSlide);
|
||||
|
||||
ListenableFuture<Void> sendResult = sendMediaMessage(recipient.getId(),
|
||||
forceSms,
|
||||
"",
|
||||
slideDeck,
|
||||
inputPanel.getQuote().orNull(),
|
||||
Collections.emptyList(),
|
||||
Collections.emptyList(),
|
||||
composeText.getMentions(),
|
||||
expiresIn,
|
||||
false,
|
||||
subscriptionId,
|
||||
initiating,
|
||||
true);
|
||||
|
||||
sendResult.addListener(new AssertedSuccessListener<Void>() {
|
||||
@Override
|
||||
public void onSuccess(Void nothing) {
|
||||
new AsyncTask<Void, Void, Void>() {
|
||||
@Override
|
||||
protected Void doInBackground(Void... params) {
|
||||
BlobProvider.getInstance().delete(ConversationActivity.this, result.first());
|
||||
return null;
|
||||
}
|
||||
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
|
||||
}
|
||||
});
|
||||
public void onSuccess(final @NonNull VoiceNoteDraft result) {
|
||||
sendVoiceNote(result.getUri(), result.getSize());
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -3120,22 +3153,12 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
|||
getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED);
|
||||
|
||||
ListenableFuture<Pair<Uri, Long>> future = audioRecorder.stopRecording();
|
||||
future.addListener(new ListenableFuture.Listener<Pair<Uri, Long>>() {
|
||||
@Override
|
||||
public void onSuccess(final Pair<Uri, Long> result) {
|
||||
new AsyncTask<Void, Void, Void>() {
|
||||
@Override
|
||||
protected Void doInBackground(Void... params) {
|
||||
BlobProvider.getInstance().delete(ConversationActivity.this, result.first());
|
||||
return null;
|
||||
}
|
||||
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(ExecutionException e) {}
|
||||
});
|
||||
ListenableFuture<VoiceNoteDraft> future = audioRecorder.stopRecording();
|
||||
if (getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.RESUMED)) {
|
||||
future.addListener(new DeleteCanceledVoiceNoteListener());
|
||||
} else {
|
||||
draftViewModel.setVoiceNoteDraftFuture(future);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -3194,6 +3217,37 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
|||
container.hideAttachedInput(true);
|
||||
}
|
||||
|
||||
private void sendVoiceNote(@NonNull Uri uri, long size) {
|
||||
boolean forceSms = sendButton.isManualSelection() && sendButton.getSelectedTransport().isSms();
|
||||
boolean initiating = threadId == -1;
|
||||
int subscriptionId = sendButton.getSelectedTransport().getSimSubscriptionId().or(-1);
|
||||
long expiresIn = recipient.get().getExpireMessages() * 1000L;
|
||||
AudioSlide audioSlide = new AudioSlide(ConversationActivity.this, uri, size, MediaUtil.AUDIO_AAC, true);
|
||||
SlideDeck slideDeck = new SlideDeck();
|
||||
slideDeck.addSlide(audioSlide);
|
||||
|
||||
ListenableFuture<Void> sendResult = sendMediaMessage(recipient.getId(),
|
||||
forceSms,
|
||||
"",
|
||||
slideDeck,
|
||||
inputPanel.getQuote().orNull(),
|
||||
Collections.emptyList(),
|
||||
Collections.emptyList(),
|
||||
composeText.getMentions(),
|
||||
expiresIn,
|
||||
false,
|
||||
subscriptionId,
|
||||
initiating,
|
||||
true);
|
||||
|
||||
sendResult.addListener(new AssertedSuccessListener<Void>() {
|
||||
@Override
|
||||
public void onSuccess(Void nothing) {
|
||||
draftViewModel.deleteBlob(uri);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void sendSticker(@NonNull StickerRecord stickerRecord, boolean clearCompose) {
|
||||
sendSticker(new StickerLocator(stickerRecord.getPackId(), stickerRecord.getPackKey(), stickerRecord.getStickerId(), stickerRecord.getEmoji()), stickerRecord.getContentType(), stickerRecord.getUri(), stickerRecord.getSize(), clearCompose);
|
||||
|
||||
|
@ -3297,8 +3351,39 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
|||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onVoiceNoteDraftPlay(@NonNull Uri audioUri, double progress) {
|
||||
voiceNoteMediaController.startSinglePlaybackForDraft(audioUri, threadId, progress);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onVoiceNoteDraftPause(@NonNull Uri audioUri) {
|
||||
voiceNoteMediaController.pausePlayback(audioUri);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onVoiceNoteDraftSeekTo(@NonNull Uri audioUri, double progress) {
|
||||
voiceNoteMediaController.seekToPosition(audioUri, progress);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onVoiceNoteDraftDelete(@NonNull Uri audioUri) {
|
||||
voiceNoteMediaController.stopPlaybackAndReset(audioUri);
|
||||
draftViewModel.deleteVoiceNoteDraft();
|
||||
}
|
||||
|
||||
// Listeners
|
||||
|
||||
private final class DeleteCanceledVoiceNoteListener implements ListenableFuture.Listener<VoiceNoteDraft> {
|
||||
@Override
|
||||
public void onSuccess(final VoiceNoteDraft result) {
|
||||
draftViewModel.deleteBlob(result.getUri());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(ExecutionException e) {}
|
||||
}
|
||||
|
||||
private class QuickCameraToggleListener implements OnClickListener {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
|
@ -3563,6 +3648,36 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
|||
reactionDelegate.showMask(maskTarget, titleView.getMeasuredHeight(), inputAreaHeight());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onVoiceNotePause(@NonNull Uri uri) {
|
||||
voiceNoteMediaController.pausePlayback(uri);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onVoiceNotePlay(@NonNull Uri uri, long messageId, double progress) {
|
||||
voiceNoteMediaController.startConsecutivePlayback(uri, messageId, progress);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onVoiceNoteSeekTo(@NonNull Uri uri, double progress) {
|
||||
voiceNoteMediaController.seekToPosition(uri, progress);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onVoiceNotePlaybackSpeedChanged(@NonNull Uri uri, float speed) {
|
||||
voiceNoteMediaController.setPlaybackSpeed(uri, speed);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRegisterVoiceNoteCallbacks(@NonNull Observer<VoiceNotePlaybackState> onPlaybackStartObserver) {
|
||||
voiceNoteMediaController.getVoiceNotePlaybackState().observe(this, onPlaybackStartObserver);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUnregisterVoiceNoteCallbacks(@NonNull Observer<VoiceNotePlaybackState> onPlaybackStartObserver) {
|
||||
voiceNoteMediaController.getVoiceNotePlaybackState().removeObserver(onPlaybackStartObserver);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCursorChanged() {
|
||||
if (!reactionDelegate.isShowing()) {
|
||||
|
|
|
@ -81,7 +81,6 @@ import org.thoughtcrime.securesms.components.TooltipPopup;
|
|||
import org.thoughtcrime.securesms.components.TypingStatusRepository;
|
||||
import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager;
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState;
|
||||
import org.thoughtcrime.securesms.contactshare.Contact;
|
||||
import org.thoughtcrime.securesms.contactshare.ContactUtil;
|
||||
|
@ -214,7 +213,6 @@ public class ConversationFragment extends LoggingFragment {
|
|||
private Animation mentionButtonOutAnimation;
|
||||
private OnScrollListener conversationScrollListener;
|
||||
private int pulsePosition = -1;
|
||||
private VoiceNoteMediaController voiceNoteMediaController;
|
||||
private View toolbarShadow;
|
||||
private ColorizerView colorizerView;
|
||||
private Stopwatch startupStopwatch;
|
||||
|
@ -408,7 +406,6 @@ public class ConversationFragment extends LoggingFragment {
|
|||
initializeResources();
|
||||
initializeMessageRequestViewModel();
|
||||
initializeListAdapter();
|
||||
voiceNoteMediaController = new VoiceNoteMediaController((AppCompatActivity) requireActivity());
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -1305,6 +1302,12 @@ public class ConversationFragment extends LoggingFragment {
|
|||
void onListVerticalTranslationChanged(float translationY);
|
||||
void onMessageWithErrorClicked(@NonNull MessageRecord messageRecord);
|
||||
void handleReactionDetails(@NonNull MaskView.MaskTarget maskTarget);
|
||||
void onVoiceNotePause(@NonNull Uri uri);
|
||||
void onVoiceNotePlay(@NonNull Uri uri, long messageId, double progress);
|
||||
void onVoiceNoteSeekTo(@NonNull Uri uri, double progress);
|
||||
void onVoiceNotePlaybackSpeedChanged(@NonNull Uri uri, float speed);
|
||||
void onRegisterVoiceNoteCallbacks(@NonNull Observer<VoiceNotePlaybackState> onPlaybackStartObserver);
|
||||
void onUnregisterVoiceNoteCallbacks(@NonNull Observer<VoiceNotePlaybackState> onPlaybackStartObserver);
|
||||
}
|
||||
|
||||
private class ConversationScrollListener extends OnScrollListener {
|
||||
|
@ -1581,32 +1584,32 @@ public class ConversationFragment extends LoggingFragment {
|
|||
|
||||
@Override
|
||||
public void onVoiceNotePause(@NonNull Uri uri) {
|
||||
voiceNoteMediaController.pausePlayback(uri);
|
||||
listener.onVoiceNotePause(uri);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onVoiceNotePlay(@NonNull Uri uri, long messageId, double progress) {
|
||||
voiceNoteMediaController.startConsecutivePlayback(uri, messageId, progress);
|
||||
listener.onVoiceNotePlay(uri, messageId, progress);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onVoiceNoteSeekTo(@NonNull Uri uri, double progress) {
|
||||
voiceNoteMediaController.seekToPosition(uri, progress);
|
||||
listener.onVoiceNoteSeekTo(uri, progress);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onVoiceNotePlaybackSpeedChanged(@NonNull Uri uri, float speed) {
|
||||
voiceNoteMediaController.setPlaybackSpeed(uri, speed);
|
||||
listener.onVoiceNotePlaybackSpeedChanged(uri, speed);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRegisterVoiceNoteCallbacks(@NonNull Observer<VoiceNotePlaybackState> onPlaybackStartObserver) {
|
||||
voiceNoteMediaController.getVoiceNotePlaybackState().observe(getViewLifecycleOwner(), onPlaybackStartObserver);
|
||||
listener.onRegisterVoiceNoteCallbacks(onPlaybackStartObserver);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUnregisterVoiceNoteCallbacks(@NonNull Observer<VoiceNotePlaybackState> onPlaybackStartObserver) {
|
||||
voiceNoteMediaController.getVoiceNotePlaybackState().removeObserver(onPlaybackStartObserver);
|
||||
listener.onUnregisterVoiceNoteCallbacks(onPlaybackStartObserver);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -0,0 +1,91 @@
|
|||
package org.thoughtcrime.securesms.conversation
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.LinearLayoutCompat
|
||||
import androidx.lifecycle.Observer
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.AudioView
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState
|
||||
import org.thoughtcrime.securesms.database.DraftDatabase
|
||||
import org.thoughtcrime.securesms.mms.AudioSlide
|
||||
|
||||
class VoiceNoteDraftView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : LinearLayoutCompat(context, attrs, defStyleAttr) {
|
||||
|
||||
var listener: Listener? = null
|
||||
|
||||
var draft: DraftDatabase.Draft? = null
|
||||
private set
|
||||
|
||||
private lateinit var audioView: AudioView
|
||||
|
||||
val playbackStateObserver: Observer<VoiceNotePlaybackState>
|
||||
get() = audioView.playbackStateObserver
|
||||
|
||||
init {
|
||||
inflate(context, R.layout.voice_note_draft_view, this)
|
||||
|
||||
val delete: View = findViewById(R.id.voice_note_draft_delete)
|
||||
|
||||
delete.setOnClickListener {
|
||||
if (draft != null) {
|
||||
val uri = audioView.audioSlideUri
|
||||
if (uri != null) {
|
||||
listener?.onVoiceNoteDraftDelete(uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
audioView = findViewById(R.id.voice_note_audio_view)
|
||||
}
|
||||
|
||||
fun clearDraft() {
|
||||
this.draft = null
|
||||
}
|
||||
|
||||
fun setDraft(draft: DraftDatabase.Draft) {
|
||||
audioView.setAudio(
|
||||
AudioSlide.createFromVoiceNoteDraft(context, draft),
|
||||
AudioViewCallbacksAdapter(),
|
||||
true,
|
||||
false
|
||||
)
|
||||
|
||||
this.draft = draft
|
||||
}
|
||||
|
||||
private inner class AudioViewCallbacksAdapter : AudioView.Callbacks {
|
||||
override fun onPlay(audioUri: Uri, progress: Double) {
|
||||
listener?.onVoiceNoteDraftPlay(audioUri, progress)
|
||||
}
|
||||
|
||||
override fun onPause(audioUri: Uri) {
|
||||
listener?.onVoiceNoteDraftPause(audioUri)
|
||||
}
|
||||
|
||||
override fun onSeekTo(audioUri: Uri, progress: Double) {
|
||||
listener?.onVoiceNoteDraftSeekTo(audioUri, progress)
|
||||
}
|
||||
|
||||
override fun onStopAndReset(audioUri: Uri) {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
override fun onProgressUpdated(durationMillis: Long, playheadMillis: Long) = Unit
|
||||
|
||||
override fun onSpeedChanged(speed: Float, isPlaying: Boolean) = Unit
|
||||
}
|
||||
|
||||
interface Listener {
|
||||
fun onVoiceNoteDraftPlay(audioUri: Uri, progress: Double)
|
||||
fun onVoiceNoteDraftPause(audioUri: Uri)
|
||||
fun onVoiceNoteDraftSeekTo(audioUri: Uri, progress: Double)
|
||||
fun onVoiceNoteDraftDelete(audioUri: Uri)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
package org.thoughtcrime.securesms.conversation.drafts
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.thoughtcrime.securesms.database.DraftDatabase
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider
|
||||
|
||||
class DraftRepository(private val context: Context) {
|
||||
fun deleteVoiceNoteDraft(draft: DraftDatabase.Draft) {
|
||||
deleteBlob(Uri.parse(draft.value).buildUpon().clearQuery().build())
|
||||
}
|
||||
|
||||
fun deleteBlob(uri: Uri) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
BlobProvider.getInstance().delete(context, uri)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
package org.thoughtcrime.securesms.conversation.drafts
|
||||
|
||||
import org.thoughtcrime.securesms.database.DraftDatabase
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
/**
|
||||
* State object responsible for holding Voice Note draft state. The intention is to allow
|
||||
* other pieces of draft state to be held here as well in the future, and to serve as a
|
||||
* management pattern going forward for drafts.
|
||||
*/
|
||||
data class DraftState(
|
||||
val recipientId: RecipientId = Recipient.UNKNOWN.id,
|
||||
val voiceNoteDraft: DraftDatabase.Draft? = null
|
||||
)
|
|
@ -0,0 +1,87 @@
|
|||
package org.thoughtcrime.securesms.conversation.drafts
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteDraft
|
||||
import org.thoughtcrime.securesms.database.DraftDatabase
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
|
||||
/**
|
||||
* ViewModel responsible for holding Voice Note draft state. The intention is to allow
|
||||
* other pieces of draft state to be held here as well in the future, and to serve as a
|
||||
* management pattern going forward for drafts.
|
||||
*/
|
||||
class DraftViewModel(
|
||||
private val repository: DraftRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val store = Store<DraftState>(DraftState())
|
||||
|
||||
val state: LiveData<DraftState> = store.stateLiveData
|
||||
|
||||
private var voiceNoteDraftFuture: ListenableFuture<VoiceNoteDraft>? = null
|
||||
|
||||
val voiceNoteDraft: DraftDatabase.Draft?
|
||||
get() = store.state.voiceNoteDraft
|
||||
|
||||
fun consumeVoiceNoteDraftFuture(): ListenableFuture<VoiceNoteDraft>? {
|
||||
val future = voiceNoteDraftFuture
|
||||
voiceNoteDraftFuture = null
|
||||
|
||||
return future
|
||||
}
|
||||
|
||||
fun setVoiceNoteDraftFuture(voiceNoteDraftFuture: ListenableFuture<VoiceNoteDraft>) {
|
||||
this.voiceNoteDraftFuture = voiceNoteDraftFuture
|
||||
}
|
||||
|
||||
fun setVoiceNoteDraft(recipientId: RecipientId, draft: DraftDatabase.Draft) {
|
||||
store.update {
|
||||
it.copy(recipientId = recipientId, voiceNoteDraft = draft)
|
||||
}
|
||||
}
|
||||
|
||||
@get:JvmName("hasVoiceNoteDraft")
|
||||
val hasVoiceNoteDraft: Boolean
|
||||
get() = store.state.voiceNoteDraft != null
|
||||
|
||||
fun clearVoiceNoteDraft() {
|
||||
store.update {
|
||||
it.copy(voiceNoteDraft = null)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteVoiceNoteDraft() {
|
||||
val draft = store.state.voiceNoteDraft
|
||||
if (draft != null) {
|
||||
clearVoiceNoteDraft()
|
||||
repository.deleteVoiceNoteDraft(draft)
|
||||
}
|
||||
}
|
||||
|
||||
fun onRecipientChanged(recipient: Recipient) {
|
||||
store.update {
|
||||
if (recipient.id != it.recipientId) {
|
||||
it.copy(recipientId = recipient.id, voiceNoteDraft = null)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteBlob(uri: Uri) {
|
||||
repository.deleteBlob(uri)
|
||||
}
|
||||
|
||||
class Factory(private val repository: DraftRepository) : ViewModelProvider.Factory {
|
||||
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return requireNotNull(modelClass.cast(DraftViewModel(repository)))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -10,6 +10,8 @@ import androidx.annotation.Nullable;
|
|||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
import org.thoughtcrime.securesms.util.CursorUtil;
|
||||
import org.thoughtcrime.securesms.util.SqlUtil;
|
||||
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
@ -34,16 +36,26 @@ public class DraftDatabase extends Database {
|
|||
super(context, databaseHelper);
|
||||
}
|
||||
|
||||
public void insertDrafts(long threadId, List<Draft> drafts) {
|
||||
public void replaceDrafts(long threadId, List<Draft> drafts) {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
|
||||
for (Draft draft : drafts) {
|
||||
ContentValues values = new ContentValues(3);
|
||||
values.put(THREAD_ID, threadId);
|
||||
values.put(DRAFT_TYPE, draft.getType());
|
||||
values.put(DRAFT_VALUE, draft.getValue());
|
||||
try {
|
||||
db.beginTransaction();
|
||||
|
||||
db.insert(TABLE_NAME, null, values);
|
||||
db.delete(TABLE_NAME, THREAD_ID + " = ?", SqlUtil.buildArgs(threadId));
|
||||
|
||||
for (Draft draft : drafts) {
|
||||
ContentValues values = new ContentValues(3);
|
||||
values.put(THREAD_ID, threadId);
|
||||
values.put(DRAFT_TYPE, draft.getType());
|
||||
values.put(DRAFT_VALUE, draft.getValue());
|
||||
|
||||
db.insert(TABLE_NAME, null, values);
|
||||
}
|
||||
|
||||
db.setTransactionSuccessful();
|
||||
} finally {
|
||||
db.endTransaction();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -89,14 +101,33 @@ public class DraftDatabase extends Database {
|
|||
}
|
||||
}
|
||||
|
||||
public @NonNull Drafts getAllVoiceNoteDrafts() {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
Drafts results = new Drafts();
|
||||
String where = DRAFT_TYPE + " = ?";
|
||||
String[] args = SqlUtil.buildArgs(Draft.VOICE_NOTE);
|
||||
|
||||
try (Cursor cursor = db.query(TABLE_NAME, null, where, args, null, null, null)) {
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
String type = CursorUtil.requireString(cursor, DRAFT_TYPE);
|
||||
String value = CursorUtil.requireString(cursor, DRAFT_VALUE);
|
||||
|
||||
results.add(new Draft(type, value));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
public static class Draft {
|
||||
public static final String TEXT = "text";
|
||||
public static final String IMAGE = "image";
|
||||
public static final String VIDEO = "video";
|
||||
public static final String AUDIO = "audio";
|
||||
public static final String LOCATION = "location";
|
||||
public static final String QUOTE = "quote";
|
||||
public static final String MENTION = "mention";
|
||||
public static final String TEXT = "text";
|
||||
public static final String IMAGE = "image";
|
||||
public static final String VIDEO = "video";
|
||||
public static final String AUDIO = "audio";
|
||||
public static final String LOCATION = "location";
|
||||
public static final String QUOTE = "quote";
|
||||
public static final String MENTION = "mention";
|
||||
public static final String VOICE_NOTE = "voice_note";
|
||||
|
||||
private final String type;
|
||||
private final String value;
|
||||
|
@ -116,13 +147,14 @@ public class DraftDatabase extends Database {
|
|||
|
||||
String getSnippet(Context context) {
|
||||
switch (type) {
|
||||
case TEXT: return value;
|
||||
case IMAGE: return context.getString(R.string.DraftDatabase_Draft_image_snippet);
|
||||
case VIDEO: return context.getString(R.string.DraftDatabase_Draft_video_snippet);
|
||||
case AUDIO: return context.getString(R.string.DraftDatabase_Draft_audio_snippet);
|
||||
case LOCATION: return context.getString(R.string.DraftDatabase_Draft_location_snippet);
|
||||
case QUOTE: return context.getString(R.string.DraftDatabase_Draft_quote_snippet);
|
||||
default: return null;
|
||||
case TEXT: return value;
|
||||
case IMAGE: return context.getString(R.string.DraftDatabase_Draft_image_snippet);
|
||||
case VIDEO: return context.getString(R.string.DraftDatabase_Draft_video_snippet);
|
||||
case AUDIO: return context.getString(R.string.DraftDatabase_Draft_audio_snippet);
|
||||
case LOCATION: return context.getString(R.string.DraftDatabase_Draft_location_snippet);
|
||||
case QUOTE: return context.getString(R.string.DraftDatabase_Draft_quote_snippet);
|
||||
case VOICE_NOTE: return context.getString(R.string.DraftDatabase_Draft_voice_note);
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,12 +26,39 @@ import androidx.annotation.NonNull;
|
|||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.attachments.Attachment;
|
||||
import org.thoughtcrime.securesms.attachments.UriAttachment;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteDraft;
|
||||
import org.thoughtcrime.securesms.database.AttachmentDatabase;
|
||||
import org.thoughtcrime.securesms.database.DraftDatabase;
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider;
|
||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
|
||||
public class AudioSlide extends Slide {
|
||||
|
||||
public static @NonNull AudioSlide createFromVoiceNoteDraft(@NonNull Context context, @NonNull DraftDatabase.Draft draft) {
|
||||
VoiceNoteDraft voiceNoteDraft = VoiceNoteDraft.fromDraft(draft);
|
||||
|
||||
return new AudioSlide(context, new UriAttachment(voiceNoteDraft.getUri(),
|
||||
MediaUtil.AUDIO_AAC,
|
||||
AttachmentDatabase.TRANSFER_PROGRESS_DONE,
|
||||
voiceNoteDraft.getSize(),
|
||||
0,
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null));
|
||||
}
|
||||
|
||||
public AudioSlide(Context context, Uri uri, long dataSize, boolean voiceNote) {
|
||||
super(context, constructAttachmentFromUri(context, uri, MediaUtil.AUDIO_UNSPECIFIED, dataSize, 0, 0, false, null, null, null, null, null, voiceNote, false, false, false));
|
||||
}
|
||||
|
|
|
@ -17,10 +17,13 @@ import org.signal.core.util.StreamUtil;
|
|||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.BuildConfig;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteDraft;
|
||||
import org.thoughtcrime.securesms.crypto.AttachmentSecret;
|
||||
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider;
|
||||
import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream;
|
||||
import org.thoughtcrime.securesms.crypto.ModernEncryptingPartOutputStream;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.DraftDatabase;
|
||||
import org.thoughtcrime.securesms.util.IOFunction;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.video.ByteArrayMediaDataSource;
|
||||
|
@ -32,10 +35,13 @@ import java.io.IOException;
|
|||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Allows for the creation and retrieval of blobs.
|
||||
|
@ -44,8 +50,9 @@ public class BlobProvider {
|
|||
|
||||
private static final String TAG = Log.tag(BlobProvider.class);
|
||||
|
||||
private static final String MULTI_SESSION_DIRECTORY = "multi_session_blobs";
|
||||
private static final String SINGLE_SESSION_DIRECTORY = "single_session_blobs";
|
||||
private static final String DRAFT_ATTACHMENTS_DIRECTORY = "draft_blobs";
|
||||
private static final String MULTI_SESSION_DIRECTORY = "multi_session_blobs";
|
||||
private static final String SINGLE_SESSION_DIRECTORY = "single_session_blobs";
|
||||
|
||||
public static final String AUTHORITY = BuildConfig.APPLICATION_ID + ".blob";
|
||||
public static final Uri CONTENT_URI = Uri.parse("content://" + AUTHORITY + "/blob");
|
||||
|
@ -219,6 +226,8 @@ public class BlobProvider {
|
|||
Log.w(TAG, "Null directory listing!");
|
||||
}
|
||||
|
||||
deleteOrphanedDraftFiles(context);
|
||||
|
||||
Log.i(TAG, "Initialized.");
|
||||
initialized = true;
|
||||
notifyAll();
|
||||
|
@ -226,6 +235,38 @@ public class BlobProvider {
|
|||
});
|
||||
}
|
||||
|
||||
private static void deleteOrphanedDraftFiles(@NonNull Context context) {
|
||||
File directory = getOrCreateDirectory(context, DRAFT_ATTACHMENTS_DIRECTORY);
|
||||
File[] files = directory.listFiles();
|
||||
|
||||
if (files == null || files.length == 0) {
|
||||
Log.d(TAG, "No attachment drafts exist. Skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
DraftDatabase draftDatabase = DatabaseFactory.getDraftDatabase(context);
|
||||
DraftDatabase.Drafts voiceNoteDrafts = draftDatabase.getAllVoiceNoteDrafts();
|
||||
|
||||
@SuppressWarnings("ConstantConditions")
|
||||
List<String> draftFileNames = voiceNoteDrafts.stream()
|
||||
.map(VoiceNoteDraft::fromDraft)
|
||||
.map(VoiceNoteDraft::getUri)
|
||||
.map(BlobProvider::getId)
|
||||
.filter(Objects::nonNull)
|
||||
.map(BlobProvider::buildFileName)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
for (final File file : files) {
|
||||
if (!draftFileNames.contains(file.getName())) {
|
||||
if (file.delete()) {
|
||||
Log.d(TAG, "Deleted orphaned attachment draft: " + file.getName());
|
||||
} else {
|
||||
Log.d(TAG, "Failed to delete orphaned attachment draft: " + file.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static @Nullable String getMimeType(@NonNull Uri uri) {
|
||||
if (isAuthority(uri)) {
|
||||
return uri.getPathSegments().get(MIMETYPE_PATH_SEGMENT);
|
||||
|
@ -344,6 +385,17 @@ public class BlobProvider {
|
|||
}
|
||||
|
||||
private static @NonNull String getDirectory(@NonNull StorageType storageType) {
|
||||
switch (storageType) {
|
||||
case SINGLE_USE_MEMORY:
|
||||
case SINGLE_SESSION_MEMORY:
|
||||
throw new IllegalArgumentException("In-Memory Blobs do not have directories.");
|
||||
case SINGLE_SESSION_DISK:
|
||||
return SINGLE_SESSION_DIRECTORY;
|
||||
case MULTI_SESSION_DISK:
|
||||
return MULTI_SESSION_DIRECTORY;
|
||||
case ATTACHMENT_DRAFT:
|
||||
return DRAFT_ATTACHMENTS_DIRECTORY;
|
||||
}
|
||||
return storageType == StorageType.MULTI_SESSION_DISK ? MULTI_SESSION_DIRECTORY : SINGLE_SESSION_DIRECTORY;
|
||||
}
|
||||
|
||||
|
@ -417,6 +469,7 @@ public class BlobProvider {
|
|||
* Create a blob that will exist for multiple app sessions. It is the caller's responsibility to
|
||||
* eventually call {@link BlobProvider#delete(Context, Uri)} when the blob is no longer in use.
|
||||
*/
|
||||
@Deprecated
|
||||
@WorkerThread
|
||||
public Uri createForMultipleSessionsOnDisk(@NonNull Context context) throws IOException {
|
||||
return writeBlobSpecToDisk(context, buildBlobSpec(StorageType.MULTI_SESSION_DISK));
|
||||
|
@ -431,12 +484,12 @@ public class BlobProvider {
|
|||
* when the blob is no longer in use.
|
||||
*/
|
||||
@WorkerThread
|
||||
public Uri createForMultipleSessionsOnDiskAsync(@NonNull Context context,
|
||||
@Nullable SuccessListener successListener,
|
||||
@Nullable ErrorListener errorListener)
|
||||
public Uri createForDraftAttachmentAsync(@NonNull Context context,
|
||||
@Nullable SuccessListener successListener,
|
||||
@Nullable ErrorListener errorListener)
|
||||
throws IOException
|
||||
{
|
||||
return writeBlobSpecToDiskAsync(context, buildBlobSpec(StorageType.MULTI_SESSION_DISK), successListener, errorListener);
|
||||
return writeBlobSpecToDiskAsync(context, buildBlobSpec(StorageType.ATTACHMENT_DRAFT), successListener, errorListener);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -555,7 +608,8 @@ public class BlobProvider {
|
|||
SINGLE_USE_MEMORY("single-use-memory", true),
|
||||
SINGLE_SESSION_MEMORY("single-session-memory", true),
|
||||
SINGLE_SESSION_DISK("single-session-disk", false),
|
||||
MULTI_SESSION_DISK("multi-session-disk", false);
|
||||
MULTI_SESSION_DISK("multi-session-disk", false),
|
||||
ATTACHMENT_DRAFT("attachment-draft", false);
|
||||
|
||||
private final String encoded;
|
||||
private final boolean inMemory;
|
||||
|
|
|
@ -47,6 +47,10 @@ public class BlobDataSource implements DataSource {
|
|||
}
|
||||
|
||||
long size = unwrapLong(BlobProvider.getFileSize(uri));
|
||||
if (size == 0) {
|
||||
size = BlobProvider.getInstance().calculateFileSize(context, uri);
|
||||
}
|
||||
|
||||
if (size - dataSpec.position <= 0) throw new EOFException("No more data");
|
||||
|
||||
return size - dataSpec.position;
|
||||
|
|
|
@ -1,5 +1,9 @@
|
|||
<vector android:autoMirrored="true" android:height="24dp"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="#FFFFFFFF" android:pathData="M16.43,4 L15.37,2.151A1.5,1.5 0,0 0,14.135 1.5L9.866,1.5a1.5,1.5 0,0 0,-1.244 0.663h0L7.571,4L2,4L2,5.5L4,5.5L4,19a3,3 0,0 0,3 3L17,22a3,3 0,0 0,3 -3L20,5.5h2L22,4ZM9,18L7.5,18L7.5,8L9,8ZM12.75,18h-1.5L11.25,8h1.5ZM9.3,4l0.572,-1h4.257L14.7,4ZM16.5,18L15,18L15,8h1.5Z"/>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M16.38,4.5a4.49,4.49 0,0 0,-8.76 0L2,4.5L2,6L3.5,6L4.86,20A2.25,2.25 0,0 0,7.1 22h9.8a2.25,2.25 0,0 0,2.24 -2L20.5,6L22,6L22,4.5ZM12,2.5a3,3 0,0 1,2.82 2L9.18,4.5A3,3 0,0 1,12 2.5ZM8,18 L7.5,8L9,8l0.5,10ZM12.75,18h-1.5L11.25,8h1.5ZM16,18L14.5,18L15,8h1.5Z"/>
|
||||
</vector>
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M16.38,4.5a4.49,4.49 0,0 0,-8.76 0L2,4.5L2,6L3.5,6L4.86,20A2.25,2.25 0,0 0,7.1 22h9.8a2.25,2.25 0,0 0,2.24 -2L20.5,6L22,6L22,4.5ZM12,2.5a3,3 0,0 1,2.82 2L9.18,4.5A3,3 0,0 1,12 2.5ZM8,18 L7.5,8L9,8l0.5,10ZM12.75,18h-1.5L11.25,8h1.5ZM16,18L14.5,18L15,8h1.5Z"/>
|
||||
</vector>
|
|
@ -1,5 +1,9 @@
|
|||
<vector android:autoMirrored="true" android:height="24dp"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="#FFFFFFFF" android:pathData="M12.75,18h-1.5L11.25,8h1.5ZM16.5,8L15,8L15,18h1.5ZM9,8L7.5,8L7.5,18L9,18ZM22,5.5L20,5.5L20,19a3,3 0,0 1,-3 3L7,22a3,3 0,0 1,-3 -3L4,5.5L2,5.5L2,4L7.571,4l1.05,-1.837h0A1.5,1.5 0,0 1,9.866 1.5h4.269a1.5,1.5 0,0 1,1.235 0.651L16.43,4L22,4ZM9.3,4h5.4l-0.573,-1L9.871,3ZM18.5,5.5L5.5,5.5L5.5,19A1.5,1.5 0,0 0,7 20.5L17,20.5A1.5,1.5 0,0 0,18.5 19Z"/>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M22,4.5L16.35,4.5a4.45,4.45 0,0 0,-8.7 0L2,4.5L2,6L3.5,6L4.86,20A2.25,2.25 0,0 0,7.1 22h9.8a2.25,2.25 0,0 0,2.24 -2L20.5,6L22,6ZM12,2.5a3,3 0,0 1,2.82 2L9.18,4.5A3,3 0,0 1,12 2.5ZM17.65,19.83a0.76,0.76 0,0 1,-0.75 0.67L7.1,20.5a0.76,0.76 0,0 1,-0.75 -0.67L5,6L19,6ZM11.25,18L11.25,8h1.5L12.75,18ZM14.5,18L15,8h1.5L16,18ZM8,18 L7.5,8L9,8l0.5,10Z"/>
|
||||
</vector>
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M16.38,4.5a4.49,4.49 0,0 0,-8.76 0L2,4.5L2,6L3.5,6L4.86,20A2.25,2.25 0,0 0,7.1 22h9.8a2.25,2.25 0,0 0,2.24 -2L20.5,6L22,6L22,4.5ZM12,2.5a3,3 0,0 1,2.82 2L9.18,4.5A3,3 0,0 1,12 2.5ZM8,18 L7.5,8L9,8l0.5,10ZM12.75,18h-1.5L11.25,8h1.5ZM16,18L14.5,18L15,8h1.5Z"/>
|
||||
</vector>
|
|
@ -0,0 +1,41 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<merge xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
tools:context="org.thoughtcrime.securesms.components.AudioView">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="@dimen/conversation_compose_height"
|
||||
android:orientation="horizontal"
|
||||
tools:background="#ff00ff">
|
||||
|
||||
<include layout="@layout/audio_view_draft_circle" />
|
||||
|
||||
<org.thoughtcrime.securesms.components.WaveFormSeekBarView
|
||||
android:id="@+id/seek"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="@dimen/conversation_compose_height"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:layout_weight="1"
|
||||
android:paddingStart="4dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingEnd="2dp"
|
||||
android:paddingBottom="8dp"
|
||||
android:thumb="@drawable/audio_wave_thumb"
|
||||
tools:progress="50" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/duration"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:layout_marginStart="12dp"
|
||||
android:textAppearance="@style/TextAppearance.Signal.Body2"
|
||||
android:textColor="@color/signal_text_hint"
|
||||
android:visibility="gone"
|
||||
tools:text="00:30"
|
||||
tools:visibility="visible" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</merge>
|
|
@ -0,0 +1,54 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<org.thoughtcrime.securesms.components.AnimatingToggle xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/control_toggle"
|
||||
android:layout_width="@dimen/conversation_compose_height"
|
||||
android:layout_height="@dimen/conversation_compose_height"
|
||||
android:layout_gravity="center"
|
||||
android:gravity="center"
|
||||
tools:showIn="@layout/audio_view_draft">
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/progress_and_play"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/circle_tintable">
|
||||
|
||||
<com.pnikosis.materialishprogress.ProgressWheel
|
||||
android:id="@+id/circle_progress"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="center"
|
||||
app:matProg_barColor="@color/white"
|
||||
app:matProg_linearProgress="true"
|
||||
app:matProg_spinSpeed="0.333" />
|
||||
|
||||
<com.airbnb.lottie.LottieAnimationView
|
||||
android:id="@+id/play"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="center"
|
||||
android:background="@drawable/circle_touch_highlight_background"
|
||||
android:contentDescription="@string/audio_view__play_pause_accessibility_description"
|
||||
android:gravity="center_vertical"
|
||||
android:padding="8dp"
|
||||
android:visibility="gone"
|
||||
app:lottie_rawRes="@raw/lottie_play_pause"
|
||||
tools:visibility="visible" />
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/download"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:background="@drawable/circle_touch_highlight_background"
|
||||
android:clickable="true"
|
||||
android:contentDescription="@string/audio_view__download_accessibility_description"
|
||||
android:focusable="true"
|
||||
android:src="@drawable/ic_download_circle_fill_white_48dp"
|
||||
android:visibility="gone" />
|
||||
|
||||
</org.thoughtcrime.securesms.components.AnimatingToggle>
|
|
@ -1,15 +1,14 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<org.thoughtcrime.securesms.components.InputPanel
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
<org.thoughtcrime.securesms.components.InputPanel xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/bottom_panel"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:background="@color/signal_background_primary"
|
||||
android:clipChildren="false"
|
||||
android:clipToPadding="false">
|
||||
android:clipToPadding="false"
|
||||
android:orientation="vertical">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/input_panel_sticker_suggestion"
|
||||
|
@ -89,9 +88,9 @@
|
|||
android:layout_height="@dimen/conversation_compose_height"
|
||||
android:layout_gravity="bottom"
|
||||
android:background="?selectableItemBackgroundBorderless"
|
||||
android:tint="@color/signal_icon_tint_primary"
|
||||
android:contentDescription="@string/conversation_activity__emoji_toggle_description"
|
||||
android:paddingEnd="6dp" />
|
||||
android:paddingEnd="6dp"
|
||||
android:tint="@color/signal_icon_tint_primary" />
|
||||
|
||||
<Space
|
||||
android:layout_width="0dp"
|
||||
|
@ -130,8 +129,8 @@
|
|||
android:id="@+id/quick_camera_toggle"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:background="?selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/conversation_activity__quick_attachment_drawer_toggle_camera_description"
|
||||
android:scaleType="fitCenter"
|
||||
|
@ -142,8 +141,8 @@
|
|||
android:id="@+id/recorder_view"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:clipChildren="false"
|
||||
android:clipToPadding="false">
|
||||
|
||||
|
@ -164,8 +163,8 @@
|
|||
android:id="@+id/inline_attachment_button"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="@dimen/conversation_compose_height"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_gravity="bottom"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:background="?selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/ConversationActivity_add_attachment"
|
||||
android:scaleType="fitCenter"
|
||||
|
@ -180,6 +179,13 @@
|
|||
|
||||
<include layout="@layout/recording_layout" />
|
||||
|
||||
<org.thoughtcrime.securesms.conversation.VoiceNoteDraftView
|
||||
android:id="@+id/voice_note_draft_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="@dimen/conversation_compose_height"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible" />
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
|
|
@ -20,7 +20,7 @@
|
|||
app:autoRewind="true"
|
||||
app:foregroundTintColor="@color/core_ultramarine"
|
||||
app:progressAndPlayTint="@android:color/transparent"
|
||||
app:small="true" />
|
||||
app:audioView_mode="small" />
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<merge xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
tools:parentTag="android.widget.LinearLayout">
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageView
|
||||
android:id="@+id/voice_note_draft_delete"
|
||||
android:layout_width="@dimen/conversation_compose_height"
|
||||
android:layout_height="@dimen/conversation_compose_height"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:layout_marginStart="4dp"
|
||||
android:background="@drawable/circle_touch_highlight_background"
|
||||
android:scaleType="centerInside"
|
||||
app:srcCompat="@drawable/ic_trash_filled_24"
|
||||
app:tint="@color/signal_alert_primary" />
|
||||
|
||||
<org.thoughtcrime.securesms.components.AudioView
|
||||
android:id="@+id/voice_note_audio_view"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="@dimen/conversation_compose_height"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_weight="1"
|
||||
app:audioView_mode="draft"
|
||||
app:foregroundTintColor="@color/signal_icon_tint_secondary"
|
||||
app:progressAndPlayTint="@android:color/transparent"
|
||||
app:waveformPlayedBarsColor="@color/signal_icon_tint_secondary"
|
||||
app:waveformThumbTint="@color/signal_icon_tint_secondary"
|
||||
app:waveformUnplayedBarsColor="@color/signal_inverse_transparent_40" />
|
||||
|
||||
</merge>
|
|
@ -72,8 +72,12 @@
|
|||
<attr name="waveformPlayedBarsColor" format="color" />
|
||||
<attr name="waveformUnplayedBarsColor" format="color" />
|
||||
<attr name="progressAndPlayTint" format="color" />
|
||||
<attr name="small" format="boolean" />
|
||||
<attr name="autoRewind" format="boolean" />
|
||||
<attr name="audioView_mode" format="enum">
|
||||
<enum name="normal" value="0" />
|
||||
<enum name="small" value="1" />
|
||||
<enum name="draft" value="2" />
|
||||
</attr>
|
||||
</declare-styleable>
|
||||
|
||||
<declare-styleable name="CircleColorImageView">
|
||||
|
|
|
@ -68,6 +68,7 @@
|
|||
<string name="DraftDatabase_Draft_video_snippet">(video)</string>
|
||||
<string name="DraftDatabase_Draft_location_snippet">(location)</string>
|
||||
<string name="DraftDatabase_Draft_quote_snippet">(reply)</string>
|
||||
<string name="DraftDatabase_Draft_voice_note">(Voice message)</string>
|
||||
|
||||
<!-- AttachmentKeyboard -->
|
||||
<string name="AttachmentKeyboard_gallery">Gallery</string>
|
||||
|
|
Ładowanie…
Reference in New Issue