Fixed attachment table queries.

Attachment audio extras job.
Job manager supports parcelable types now.
This commit is contained in:
Anton Chekulaev 2020-10-22 13:52:53 +11:00
parent 001a5a90cb
commit b6d8898ff9
14 changed files with 385 additions and 139 deletions

View File

@ -1,12 +1,15 @@
package org.thoughtcrime.securesms.attachments; package org.thoughtcrime.securesms.attachments;
import android.os.Parcel;
import android.os.Parcelable;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.Util;
public class AttachmentId { public class AttachmentId implements Parcelable {
@JsonProperty @JsonProperty
private final long rowId; private final long rowId;
@ -54,4 +57,33 @@ public class AttachmentId {
public int hashCode() { public int hashCode() {
return Util.hashCode(rowId, uniqueId); return Util.hashCode(rowId, uniqueId);
} }
//region Parcelable implementation.
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeLong(rowId);
dest.writeLong(uniqueId);
}
public static final Parcelable.Creator<AttachmentId> CREATOR =
new Parcelable.Creator<AttachmentId>() {
@Override
public AttachmentId createFromParcel(Parcel in) {
long rowId = in.readLong();
long uniqueId = in.readLong();
return new AttachmentId(rowId, uniqueId);
}
@Override
public AttachmentId[] newArray(int size) {
return new AttachmentId[size];
}
};
//endregion
} }

View File

@ -1,6 +1,15 @@
package org.thoughtcrime.securesms.attachments package org.thoughtcrime.securesms.attachments
data class DatabaseAttachmentAudioExtras(val attachmentId: AttachmentId, val visualSamples: ByteArray, val durationMs: Long) { 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 is not known. */
val durationMs: Long = DURATION_UNDEFINED) {
companion object {
const val DURATION_UNDEFINED = -1L
}
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
return other != null && return other != null &&

View File

@ -73,6 +73,8 @@ import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import kotlin.jvm.Synchronized;
public class AttachmentDatabase extends Database { public class AttachmentDatabase extends Database {
private static final String TAG = AttachmentDatabase.class.getSimpleName(); private static final String TAG = AttachmentDatabase.class.getSimpleName();
@ -106,8 +108,8 @@ public class AttachmentDatabase extends Database {
static final String CAPTION = "caption"; static final String CAPTION = "caption";
public static final String URL = "url"; public static final String URL = "url";
public static final String DIRECTORY = "parts"; public static final String DIRECTORY = "parts";
// audio/* mime type only related columns. // "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_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. 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_DONE = 0;
@ -116,7 +118,7 @@ public class AttachmentDatabase extends Database {
public static final int TRANSFER_PROGRESS_FAILED = 3; 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_ID_WHERE = ROW_ID + " = ? AND " + UNIQUE_ID + " = ?";
private static final String PART_AUDIO_ONLY_WHERE = CONTENT_TYPE + " LIKE audio/%"; private static final String PART_AUDIO_ONLY_WHERE = CONTENT_TYPE + " LIKE \"audio/%\"";
private static final String[] PROJECTION = new String[] {ROW_ID, private static final String[] PROJECTION = new String[] {ROW_ID,
MMS_ID, CONTENT_TYPE, NAME, CONTENT_DISPOSITION, MMS_ID, CONTENT_TYPE, NAME, CONTENT_DISPOSITION,
@ -834,6 +836,7 @@ public class AttachmentDatabase extends Database {
* Retrieves the audio extra values associated with the attachment. Only "audio/*" mime type attachments are accepted. * 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. * @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) { public @Nullable DatabaseAttachmentAudioExtras getAttachmentAudioExtras(@NonNull AttachmentId attachmentId) {
try (Cursor cursor = databaseHelper.getReadableDatabase() try (Cursor cursor = databaseHelper.getReadableDatabase()
// We expect all the audio extra values to be present (not null) or reject the whole record. // We expect all the audio extra values to be present (not null) or reject the whole record.
@ -859,6 +862,7 @@ public class AttachmentDatabase extends Database {
* Updates audio extra columns for the "audio/*" mime type attachments only. * Updates audio extra columns for the "audio/*" mime type attachments only.
* @return true if the update operation was successful. * @return true if the update operation was successful.
*/ */
@Synchronized
public boolean setAttachmentAudioExtras(@NonNull DatabaseAttachmentAudioExtras extras) { public boolean setAttachmentAudioExtras(@NonNull DatabaseAttachmentAudioExtras extras) {
ContentValues values = new ContentValues(); ContentValues values = new ContentValues();
values.put(AUDIO_VISUAL_SAMPLES, extras.getVisualSamples()); values.put(AUDIO_VISUAL_SAMPLES, extras.getVisualSamples());

View File

@ -103,9 +103,7 @@ public class ClassicOpenHelper extends SQLiteOpenHelper {
private static final int GROUP_RECEIPT_TRACKING = 45; private static final int GROUP_RECEIPT_TRACKING = 45;
private static final int UNREAD_COUNT_VERSION = 46; private static final int UNREAD_COUNT_VERSION = 46;
private static final int MORE_RECIPIENT_FIELDS = 47; private static final int MORE_RECIPIENT_FIELDS = 47;
private static final int AUDIO_ATTACHMENT_EXTRAS = 48; private static final int DATABASE_VERSION = 47;
private static final int DATABASE_VERSION = 48;
private static final String TAG = ClassicOpenHelper.class.getSimpleName(); private static final String TAG = ClassicOpenHelper.class.getSimpleName();
@ -1291,11 +1289,6 @@ public class ClassicOpenHelper extends SQLiteOpenHelper {
*/ */
} }
if (oldVersion < AUDIO_ATTACHMENT_EXTRAS) {
db.execSQL("ALTER TABLE part ADD COLUMN audio_visual_samples BLOB");
db.execSQL("ALTER TABLE part ADD COLUMN audio_duration INTEGER");
}
db.setTransactionSuccessful(); db.setTransactionSuccessful();
db.endTransaction(); db.endTransaction();
} }

View File

@ -92,8 +92,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
private static final int lokiV13 = 34; private static final int lokiV13 = 34;
private static final int lokiV14_BACKUP_FILES = 35; private static final int lokiV14_BACKUP_FILES = 35;
private static final int lokiV15 = 36; private static final int lokiV15 = 36;
private static final int lokiV16_AUDIO_ATTACHMENT_EXTRAS = 37;
private static final int DATABASE_VERSION = lokiV15; private static final int DATABASE_VERSION = lokiV16_AUDIO_ATTACHMENT_EXTRAS;
private static final String DATABASE_NAME = "signal.db"; private static final String DATABASE_NAME = "signal.db";
private final Context context; private final Context context;
@ -632,6 +633,11 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
db.execSQL(SharedSenderKeysDatabase.getCreateOldClosedGroupRatchetTableCommand()); db.execSQL(SharedSenderKeysDatabase.getCreateOldClosedGroupRatchetTableCommand());
} }
if (oldVersion < lokiV16_AUDIO_ATTACHMENT_EXTRAS) {
db.execSQL("ALTER TABLE part ADD COLUMN audio_visual_samples BLOB");
db.execSQL("ALTER TABLE part ADD COLUMN audio_duration INTEGER");
}
db.setTransactionSuccessful(); db.setTransactionSuccessful();
} finally { } finally {
db.endTransaction(); db.endTransaction();

View File

@ -1,13 +1,20 @@
package org.thoughtcrime.securesms.jobmanager; package org.thoughtcrime.securesms.jobmanager;
import android.os.Parcelable;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import org.thoughtcrime.securesms.attachments.AttachmentId;
import org.thoughtcrime.securesms.util.ParcelableUtil;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
//TODO AC: For now parcelable objects utilize byteArrays field to store their data into.
// Introduce a dedicated Map<String, byte[]> field specifically for parcelable needs.
public class Data { public class Data {
public static final Data EMPTY = new Data.Builder().build(); public static final Data EMPTY = new Data.Builder().build();
@ -213,6 +220,16 @@ public class Data {
return byteArrays.get(key); return byteArrays.get(key);
} }
public boolean hasParcelable(@NonNull String key) {
return byteArrays.containsKey(key);
}
public <T extends Parcelable> T getParcelable(@NonNull String key, @NonNull Parcelable.Creator<T> creator) {
throwIfAbsent(byteArrays, key);
byte[] bytes = byteArrays.get(key);
return ParcelableUtil.unmarshall(bytes, creator);
}
private void throwIfAbsent(@NonNull Map map, @NonNull String key) { private void throwIfAbsent(@NonNull Map map, @NonNull String key) {
if (!map.containsKey(key)) { if (!map.containsKey(key)) {
throw new IllegalStateException("Tried to retrieve a value with key '" + key + "', but it wasn't present."); throw new IllegalStateException("Tried to retrieve a value with key '" + key + "', but it wasn't present.");
@ -301,6 +318,12 @@ public class Data {
return this; 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() { public Data build() {
return new Data(strings, return new Data(strings,
stringArrays, stringArrays,

View File

@ -45,6 +45,7 @@ import org.thoughtcrime.securesms.jobs.TrimThreadJob;
import org.thoughtcrime.securesms.jobs.TypingSendJob; import org.thoughtcrime.securesms.jobs.TypingSendJob;
import org.thoughtcrime.securesms.jobs.UpdateApkJob; import org.thoughtcrime.securesms.jobs.UpdateApkJob;
import org.thoughtcrime.securesms.loki.api.BackgroundPollJob; 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.ClosedGroupUpdateMessageSendJob;
import org.thoughtcrime.securesms.loki.protocol.NullMessageSendJob; import org.thoughtcrime.securesms.loki.protocol.NullMessageSendJob;
import org.thoughtcrime.securesms.loki.protocol.SessionRequestMessageSendJob; import org.thoughtcrime.securesms.loki.protocol.SessionRequestMessageSendJob;
@ -102,6 +103,7 @@ public class WorkManagerFactoryMappings {
put(TrimThreadJob.class.getName(), TrimThreadJob.KEY); put(TrimThreadJob.class.getName(), TrimThreadJob.KEY);
put(TypingSendJob.class.getName(), TypingSendJob.KEY); put(TypingSendJob.class.getName(), TypingSendJob.KEY);
put(UpdateApkJob.class.getName(), UpdateApkJob.KEY); put(UpdateApkJob.class.getName(), UpdateApkJob.KEY);
put(PrepareAttachmentAudioExtrasJob.class.getName(), PrepareAttachmentAudioExtrasJob.KEY);
}}; }};
public static @Nullable String getFactoryKey(@NonNull String workManagerClass) { public static @Nullable String getFactoryKey(@NonNull String workManagerClass) {

View File

@ -15,6 +15,7 @@ import org.thoughtcrime.securesms.jobmanager.impl.NetworkOrCellServiceConstraint
import org.thoughtcrime.securesms.jobmanager.impl.SqlCipherMigrationConstraint; import org.thoughtcrime.securesms.jobmanager.impl.SqlCipherMigrationConstraint;
import org.thoughtcrime.securesms.jobmanager.impl.SqlCipherMigrationConstraintObserver; import org.thoughtcrime.securesms.jobmanager.impl.SqlCipherMigrationConstraintObserver;
import org.thoughtcrime.securesms.loki.api.BackgroundPollJob; 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.ClosedGroupUpdateMessageSendJob;
import org.thoughtcrime.securesms.loki.protocol.NullMessageSendJob; import org.thoughtcrime.securesms.loki.protocol.NullMessageSendJob;
import org.thoughtcrime.securesms.loki.protocol.SessionRequestMessageSendJob; import org.thoughtcrime.securesms.loki.protocol.SessionRequestMessageSendJob;
@ -79,6 +80,7 @@ public final class JobManagerFactories {
put(TrimThreadJob.KEY, new TrimThreadJob.Factory()); put(TrimThreadJob.KEY, new TrimThreadJob.Factory());
put(TypingSendJob.KEY, new TypingSendJob.Factory()); put(TypingSendJob.KEY, new TypingSendJob.Factory());
put(UpdateApkJob.KEY, new UpdateApkJob.Factory()); put(UpdateApkJob.KEY, new UpdateApkJob.Factory());
put(PrepareAttachmentAudioExtrasJob.KEY, new PrepareAttachmentAudioExtrasJob.Factory());
}}; }};
} }

View File

@ -26,11 +26,13 @@ import kotlinx.android.synthetic.main.activity_home.*
import network.loki.messenger.R import network.loki.messenger.R
import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.conversation.ConversationActivity import org.thoughtcrime.securesms.conversation.ConversationActivity
import org.thoughtcrime.securesms.database.DatabaseFactory import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.jobs.MultiDeviceBlockedUpdateJob 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.ConversationOptionsBottomSheet
import org.thoughtcrime.securesms.loki.dialogs.LightThemeFeatureIntroBottomSheet import org.thoughtcrime.securesms.loki.dialogs.LightThemeFeatureIntroBottomSheet
import org.thoughtcrime.securesms.loki.dialogs.MultiDeviceRemovalBottomSheet import org.thoughtcrime.securesms.loki.dialogs.MultiDeviceRemovalBottomSheet

View File

@ -0,0 +1,169 @@
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))
}
}
val audioExtras = DatabaseAttachmentAudioExtras(
attachmentId,
rmsValues,
totalDurationMs
)
attachDb.setAttachmentAudioExtras(audioExtras)
EventBus.getDefault().post(AudioExtrasUpdatedEvent(audioExtras))
}
class Factory : Job.Factory<PrepareAttachmentAudioExtrasJob> {
override fun create(parameters: Parameters, data: Data): PrepareAttachmentAudioExtrasJob {
return PrepareAttachmentAudioExtrasJob(parameters, data.getParcelable(DATA_ATTACH_ID, AttachmentId.CREATOR))
}
}
/** Dispatched once the audio extras have been updated. */
data class AudioExtrasUpdatedEvent(val audioExtras: DatabaseAttachmentAudioExtras)
@RequiresApi(Build.VERSION_CODES.M)
private class InputStreamMediaDataSource: MediaDataSource {
private val data: ByteArray
constructor(inputStream: InputStream): super() {
this.data = inputStream.readBytes()
}
override fun readAt(position: Long, buffer: ByteArray, offset: Int, size: Int): Int {
val length: Int = data.size
if (position >= length) {
return -1 // -1 indicates EOF
}
var actualSize = size
if (position + size > length) {
actualSize -= (position + size - length).toInt()
}
System.arraycopy(data, position.toInt(), buffer, offset, actualSize)
return actualSize
}
override fun getSize(): Long {
return data.size.toLong()
}
override fun close() {
// We don't need to close the wrapped stream.
}
}
}

View File

@ -16,6 +16,7 @@ import java.nio.ByteOrder
import java.nio.ShortBuffer import java.nio.ShortBuffer
import kotlin.jvm.Throws import kotlin.jvm.Throws
import kotlin.math.ceil import kotlin.math.ceil
import kotlin.math.roundToInt
import kotlin.math.sqrt import kotlin.math.sqrt
/** /**
@ -246,7 +247,7 @@ class DecodedAudio {
codec.release() codec.release()
} }
fun calculateRms(maxFrames: Int): FloatArray { fun calculateRms(maxFrames: Int): ByteArray {
return calculateRms(this.samples, this.numSamples, this.channels, maxFrames) return calculateRms(this.samples, this.numSamples, this.channels, maxFrames)
} }
} }
@ -264,9 +265,9 @@ class DecodedAudio {
* If number of samples per channel is less than "maxFrames", * If number of samples per channel is less than "maxFrames",
* the result array will match the source sample size instead. * the result array will match the source sample size instead.
* *
* @return RMS values float array where is each value is within [0..1] range. * @return normalized RMS values as a signed byte array.
*/ */
private fun calculateRms(samples: ShortBuffer, numSamples: Int, channels: Int, maxFrames: Int): FloatArray { private fun calculateRms(samples: ShortBuffer, numSamples: Int, channels: Int, maxFrames: Int): ByteArray {
val numFrames: Int val numFrames: Int
val frameStep: Float val frameStep: Float
@ -310,7 +311,8 @@ private fun calculateRms(samples: ShortBuffer, numSamples: Int, channels: Int, m
// smoothArray(rmsValues, 1.0f) // smoothArray(rmsValues, 1.0f)
normalizeArray(rmsValues) normalizeArray(rmsValues)
return rmsValues // Convert normalized result to a signed byte array.
return rmsValues.map { value -> normalizedFloatToByte(value) }.toByteArray()
} }
/** /**
@ -345,3 +347,13 @@ private fun smoothArray(values: FloatArray, neighborWeight: Float = 1f): FloatAr
} }
return result return result
} }
/** Turns a signed byte into a [0..1] float. */
inline fun byteToNormalizedFloat(value: Byte): Float {
return (value + 128f) / 255f
}
/** Turns a [0..1] float into a signed byte. */
inline fun normalizedFloatToByte(value: Float): Byte {
return (255f * value - 128f).roundToInt().toByte()
}

View File

@ -5,8 +5,6 @@ import android.content.res.ColorStateList
import android.graphics.Color import android.graphics.Color
import android.graphics.PorterDuff import android.graphics.PorterDuff
import android.graphics.drawable.AnimatedVectorDrawable import android.graphics.drawable.AnimatedVectorDrawable
import android.media.MediaDataSource
import android.os.Build
import android.util.AttributeSet import android.util.AttributeSet
import android.view.View import android.view.View
import android.view.View.OnTouchListener import android.view.View.OnTouchListener
@ -15,7 +13,6 @@ import android.widget.FrameLayout
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.annotation.RequiresApi
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.graphics.ColorUtils import androidx.core.graphics.ColorUtils
import com.pnikosis.materialishprogress.ProgressWheel import com.pnikosis.materialishprogress.ProgressWheel
@ -24,18 +21,18 @@ import network.loki.messenger.R
import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode import org.greenrobot.eventbus.ThreadMode
import org.thoughtcrime.securesms.attachments.Attachment import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.audio.AudioSlidePlayer import org.thoughtcrime.securesms.audio.AudioSlidePlayer
import org.thoughtcrime.securesms.components.AnimatingToggle import org.thoughtcrime.securesms.components.AnimatingToggle
import org.thoughtcrime.securesms.database.AttachmentDatabase import org.thoughtcrime.securesms.database.AttachmentDatabase
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.events.PartProgressEvent import org.thoughtcrime.securesms.events.PartProgressEvent
import org.thoughtcrime.securesms.logging.Log import org.thoughtcrime.securesms.logging.Log
import org.thoughtcrime.securesms.loki.utilities.audio.DecodedAudio import org.thoughtcrime.securesms.loki.api.PrepareAttachmentAudioExtrasJob
import org.thoughtcrime.securesms.mms.AudioSlide import org.thoughtcrime.securesms.mms.AudioSlide
import org.thoughtcrime.securesms.mms.PartAuthority
import org.thoughtcrime.securesms.mms.SlideClickListener import org.thoughtcrime.securesms.mms.SlideClickListener
import java.io.IOException import java.io.IOException
import java.io.InputStream
import java.util.* import java.util.*
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@ -166,7 +163,7 @@ class MessageAudioView: FrameLayout, AudioSlidePlayer.Listener {
} }
// Post to make sure it executes only when the view is attached to a window. // Post to make sure it executes only when the view is attached to a window.
post(::updateSeekBarFromAudio) post(::updateFromAttachmentAudioExtras)
} }
} }
audioSlidePlayer = AudioSlidePlayer.createFor(context, audio, this) audioSlidePlayer = AudioSlidePlayer.createFor(context, audio, this)
@ -254,83 +251,62 @@ class MessageAudioView: FrameLayout, AudioSlidePlayer.Listener {
pauseToPlayDrawable.start() pauseToPlayDrawable.start()
} }
private fun updateSeekBarFromAudio() { private fun obtainDatabaseAttachment(): DatabaseAttachment? {
if (audioSlidePlayer == null) return audioSlidePlayer ?: return null
val attachment = audioSlidePlayer!!.audioSlide.asAttachment() val attachment = audioSlidePlayer!!.audioSlide.asAttachment()
return if (attachment is DatabaseAttachment) attachment else null
// Parse audio and compute RMS values for the WaveformSeekBar in the background.
asyncCoroutineScope!!.launch {
val rmsFrames = 32 // The amount of values to be computed for the visualization.
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 = rmsFrames): FloatArray { private fun updateFromAttachmentAudioExtras() {
return Random(seed.toLong()).let { (0 until frames).map { i -> it.nextFloat() }.toFloatArray() } 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
} }
var rmsValues: FloatArray
var totalDurationMs: Long = -1
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(rmsFrames)
totalDurationMs = (decodedAudio.totalDuration / 1000.0).toLong()
} catch (e: Exception) {
android.util.Log.w(TAG, "Failed to decode sample values for the audio attachment \"${attachment.fileName}\".", e)
rmsValues = generateFakeRms(extractAttachmentRandomSeed(attachment))
}
}
android.util.Log.d(TAG, "RMS: ${rmsValues.joinToString()}")
post {
loadingAnimation.stop() loadingAnimation.stop()
seekBar.sampleData = rmsValues seekBar.sampleData = audioExtras.visualSamples
if (totalDurationMs > 0) { if (audioExtras.durationMs > 0) {
totalDuration.visibility = View.VISIBLE totalDuration.visibility = View.VISIBLE
totalDuration.text = String.format("%02d:%02d", totalDuration.text = String.format("%02d:%02d",
TimeUnit.MILLISECONDS.toMinutes(totalDurationMs), TimeUnit.MILLISECONDS.toMinutes(audioExtras.durationMs),
TimeUnit.MILLISECONDS.toSeconds(totalDurationMs)) TimeUnit.MILLISECONDS.toSeconds(audioExtras.durationMs))
}
}
} }
} }
@Subscribe(sticky = true, threadMode = ThreadMode.MAIN) @Subscribe(sticky = true, threadMode = ThreadMode.MAIN)
fun onEventAsync(event: PartProgressEvent) { fun onEvent(event: PartProgressEvent) {
if (audioSlidePlayer != null && event.attachment == audioSlidePlayer!!.audioSlide.asAttachment()) { if (audioSlidePlayer != null && event.attachment == audioSlidePlayer!!.audioSlide.asAttachment()) {
downloadProgress.setInstantProgress(event.progress.toFloat() / event.total) downloadProgress.setInstantProgress(event.progress.toFloat() / event.total)
} }
} }
}
private class SeekBarLoadingAnimation( @Subscribe(threadMode = ThreadMode.MAIN)
fun onEvent(event: PrepareAttachmentAudioExtrasJob.AudioExtrasUpdatedEvent) {
if (event.audioExtras.attachmentId == obtainDatabaseAttachment()?.attachmentId) {
updateFromAttachmentAudioExtras()
}
}
private class SeekBarLoadingAnimation(
private val hostView: View, private val hostView: View,
private val seekBar: WaveformSeekBar): Runnable { private val seekBar: WaveformSeekBar): Runnable {
companion object { companion object {
private const val UPDATE_PERIOD = 500L // In milliseconds. private const val UPDATE_PERIOD = 350L // In milliseconds.
private val random = Random() private val random = Random()
} }
fun start() { fun start() {
stop() stop()
run() hostView.postDelayed(this, UPDATE_PERIOD)
} }
fun stop() { fun stop() {
@ -338,38 +314,10 @@ private class SeekBarLoadingAnimation(
} }
override fun run() { override fun run() {
seekBar.sampleData = (0 until 64).map { random.nextFloat() * 0.5f }.toFloatArray() // 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) hostView.postDelayed(this, UPDATE_PERIOD)
} }
}
@RequiresApi(Build.VERSION_CODES.M)
private class InputStreamMediaDataSource: MediaDataSource {
private val data: ByteArray
constructor(inputStream: InputStream): super() {
this.data = inputStream.readBytes()
}
override fun readAt(position: Long, buffer: ByteArray, offset: Int, size: Int): Int {
val length: Int = data.size
if (position >= length) {
return -1 // -1 indicates EOF
}
var actualSize = size
if (position + size > length) {
actualSize -= (position + size - length).toInt()
}
System.arraycopy(data, position.toInt(), buffer, offset, actualSize)
return actualSize
}
override fun getSize(): Long {
return data.size.toLong()
}
override fun close() {
// We don't need to close the wrapped stream.
} }
} }

View File

@ -7,7 +7,6 @@ import android.graphics.Color
import android.graphics.Paint import android.graphics.Paint
import android.graphics.RectF import android.graphics.RectF
import android.util.AttributeSet import android.util.AttributeSet
import android.util.Log
import android.util.TypedValue import android.util.TypedValue
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View import android.view.View
@ -15,8 +14,11 @@ import android.view.ViewConfiguration
import android.view.animation.DecelerateInterpolator import android.view.animation.DecelerateInterpolator
import androidx.core.math.MathUtils import androidx.core.math.MathUtils
import network.loki.messenger.R import network.loki.messenger.R
import org.thoughtcrime.securesms.loki.utilities.audio.byteToNormalizedFloat
import java.lang.Math.abs import java.lang.Math.abs
import kotlin.math.max import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
class WaveformSeekBar : View { class WaveformSeekBar : View {
@ -32,8 +34,8 @@ class WaveformSeekBar : View {
} }
private val sampleDataHolder = SampleDataHolder(::invalidate) private val sampleDataHolder = SampleDataHolder(::invalidate)
/** An array if normalized to [0..1] values representing the audio signal. */ /** An array of signed byte values representing the audio signal. */
var sampleData: FloatArray? var sampleData: ByteArray?
get() { get() {
return sampleDataHolder.getSamples() return sampleDataHolder.getSamples()
} }
@ -155,7 +157,8 @@ class WaveformSeekBar : View {
var lastBarRight = paddingLeft.toFloat() var lastBarRight = paddingLeft.toFloat()
(0 until barAmount).forEach { barIdx -> (0 until barAmount).forEach { barIdx ->
val barValue = sampleDataHolder.computeBarValue(barIdx, barAmount) // Convert a signed byte to a [0..1] float.
val barValue = byteToNormalizedFloat(sampleDataHolder.computeBarValue(barIdx, barAmount))
val barHeight = max(barMinHeight, getAvailableHeight() * barValue) val barHeight = max(barMinHeight, getAvailableHeight() * barValue)
@ -246,16 +249,17 @@ class WaveformSeekBar : View {
private class SampleDataHolder(private val invalidateDelegate: () -> Any) { private class SampleDataHolder(private val invalidateDelegate: () -> Any) {
private var sampleDataFrom: FloatArray? = null private var sampleDataFrom: ByteArray? = null
private var sampleDataTo: FloatArray? = null private var sampleDataTo: ByteArray? = null
private var progress = 1f // Mix between from and to values. private var progress = 1f // Mix between from and to values.
private var animation: ValueAnimator? = null private var animation: ValueAnimator? = null
fun computeBarValue(barIdx: Int, barAmount: Int): Float { fun computeBarValue(barIdx: Int, barAmount: Int): Byte {
fun getSampleValue(sampleData: FloatArray?): Float { /** @return The array's value at the interpolated index. */
fun getSampleValue(sampleData: ByteArray?): Byte {
if (sampleData == null || sampleData.isEmpty()) if (sampleData == null || sampleData.isEmpty())
return 0f return Byte.MIN_VALUE
else { else {
val sampleIdx = (barIdx * (sampleData.size / barAmount.toFloat())).toInt() val sampleIdx = (barIdx * (sampleData.size / barAmount.toFloat())).toInt()
return sampleData[sampleIdx] return sampleData[sampleIdx]
@ -268,12 +272,21 @@ class WaveformSeekBar : View {
val fromValue = getSampleValue(sampleDataFrom) val fromValue = getSampleValue(sampleDataFrom)
val toValue = getSampleValue(sampleDataTo) val toValue = getSampleValue(sampleDataTo)
val rawResultValue = fromValue * (1f - progress) + toValue * progress
return fromValue * (1f - progress) + toValue * progress return rawResultValue.roundToInt().toByte()
} }
fun setSamples(sampleData: FloatArray?) { fun setSamples(sampleData: ByteArray?) {
sampleDataFrom = sampleDataTo /** @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 sampleDataTo = sampleData
progress = 0f progress = 0f
@ -281,7 +294,6 @@ class WaveformSeekBar : View {
animation = ValueAnimator.ofFloat(0f, 1f).apply { animation = ValueAnimator.ofFloat(0f, 1f).apply {
addUpdateListener { animation -> addUpdateListener { animation ->
progress = animation.animatedValue as Float progress = animation.animatedValue as Float
Log.d("MTPHR", "Progress: $progress")
invalidateDelegate() invalidateDelegate()
} }
interpolator = DecelerateInterpolator(3f) interpolator = DecelerateInterpolator(3f)
@ -290,7 +302,7 @@ class WaveformSeekBar : View {
} }
} }
fun getSamples(): FloatArray? { fun getSamples(): ByteArray? {
return sampleDataTo return sampleDataTo
} }
} }

View File

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