mirror of
https://github.com/oxen-io/session-android.git
synced 2024-11-28 20:45:17 +00:00
Merge branch 'dev' of https://github.com/loki-project/session-android into backup-restore
This commit is contained in:
commit
82b87d9178
16
build.gradle
16
build.gradle
@ -55,6 +55,12 @@ repositories {
|
|||||||
includeGroupByRegex "com\\.amulyakhare.*"
|
includeGroupByRegex "com\\.amulyakhare.*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
maven {
|
||||||
|
url "https://dl.bintray.com/terl/lazysodium-maven"
|
||||||
|
content {
|
||||||
|
includeGroupByRegex "com\\.goterl\\.lazycode.*"
|
||||||
|
}
|
||||||
|
}
|
||||||
google()
|
google()
|
||||||
jcenter()
|
jcenter()
|
||||||
maven { url "https://jitpack.io" }
|
maven { url "https://jitpack.io" }
|
||||||
@ -145,10 +151,13 @@ dependencies {
|
|||||||
implementation "org.whispersystems:signal-service-android:2.13.2" // Run ./gradlew install from session-android-service to install
|
implementation "org.whispersystems:signal-service-android:2.13.2" // Run ./gradlew install from session-android-service to install
|
||||||
implementation "org.whispersystems:curve25519-java:0.5.0"
|
implementation "org.whispersystems:curve25519-java:0.5.0"
|
||||||
// Remote:
|
// Remote:
|
||||||
|
implementation "com.goterl.lazycode:lazysodium-android:4.2.0@aar"
|
||||||
|
implementation "net.java.dev.jna:jna:5.5.0@aar"
|
||||||
implementation "com.google.protobuf:protobuf-java:2.5.0"
|
implementation "com.google.protobuf:protobuf-java:2.5.0"
|
||||||
implementation "com.fasterxml.jackson.core:jackson-databind:2.9.8"
|
implementation "com.fasterxml.jackson.core:jackson-databind:2.9.8"
|
||||||
implementation "com.squareup.okhttp3:okhttp:3.12.1"
|
implementation "com.squareup.okhttp3:okhttp:3.12.1"
|
||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||||
|
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9'
|
||||||
implementation 'androidx.fragment:fragment-ktx:1.3.0-beta01'
|
implementation 'androidx.fragment:fragment-ktx:1.3.0-beta01'
|
||||||
implementation 'androidx.activity:activity-ktx:1.1.0'
|
implementation 'androidx.activity:activity-ktx:1.1.0'
|
||||||
implementation "nl.komponents.kovenant:kovenant:$kovenant_version"
|
implementation "nl.komponents.kovenant:kovenant:$kovenant_version"
|
||||||
@ -183,8 +192,8 @@ dependencies {
|
|||||||
testImplementation 'org.robolectric:shadows-multidex:4.2'
|
testImplementation 'org.robolectric:shadows-multidex:4.2'
|
||||||
}
|
}
|
||||||
|
|
||||||
def canonicalVersionCode = 110
|
def canonicalVersionCode = 116
|
||||||
def canonicalVersionName = "1.6.1"
|
def canonicalVersionName = "1.6.3"
|
||||||
|
|
||||||
def postFixSize = 10
|
def postFixSize = 10
|
||||||
def abiPostFix = ['armeabi-v7a' : 1,
|
def abiPostFix = ['armeabi-v7a' : 1,
|
||||||
@ -196,7 +205,7 @@ def abiPostFix = ['armeabi-v7a' : 1,
|
|||||||
android {
|
android {
|
||||||
flavorDimensions "none"
|
flavorDimensions "none"
|
||||||
compileSdkVersion 29
|
compileSdkVersion 29
|
||||||
buildToolsVersion '28.0.3'
|
buildToolsVersion '29.0.3'
|
||||||
useLibrary 'org.apache.http.legacy'
|
useLibrary 'org.apache.http.legacy'
|
||||||
|
|
||||||
dexOptions {
|
dexOptions {
|
||||||
@ -265,6 +274,7 @@ android {
|
|||||||
proguardFiles getDefaultProguardFile('proguard-android.txt'),
|
proguardFiles getDefaultProguardFile('proguard-android.txt'),
|
||||||
'proguard-dagger.pro',
|
'proguard-dagger.pro',
|
||||||
'proguard-jackson.pro',
|
'proguard-jackson.pro',
|
||||||
|
'proguard-jna.pro',
|
||||||
'proguard-sqlite.pro',
|
'proguard-sqlite.pro',
|
||||||
'proguard-appcompat-v7.pro',
|
'proguard-appcompat-v7.pro',
|
||||||
'proguard-square-okhttp.pro',
|
'proguard-square-okhttp.pro',
|
||||||
|
3
proguard-jna.pro
Normal file
3
proguard-jna.pro
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
-dontwarn java.awt.*
|
||||||
|
-keep class com.sun.jna.* { *; }
|
||||||
|
-keepclassmembers class * extends com.sun.jna.* { public *; }
|
4
res/drawable/circle_tintable_4dp_inset.xml
Normal file
4
res/drawable/circle_tintable_4dp_inset.xml
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<inset xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:drawable="@drawable/circle_tintable"
|
||||||
|
android:inset="4dp"/>
|
@ -34,12 +34,15 @@
|
|||||||
style="@style/SessionEditText"
|
style="@style/SessionEditText"
|
||||||
android:id="@+id/mnemonicEditText"
|
android:id="@+id/mnemonicEditText"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="80dp"
|
||||||
android:paddingTop="28dp"
|
|
||||||
android:paddingBottom="28dp"
|
|
||||||
android:layout_marginLeft="@dimen/very_large_spacing"
|
android:layout_marginLeft="@dimen/very_large_spacing"
|
||||||
android:layout_marginTop="12dp"
|
android:layout_marginTop="12dp"
|
||||||
android:layout_marginRight="@dimen/very_large_spacing"
|
android:layout_marginRight="@dimen/very_large_spacing"
|
||||||
|
android:paddingTop="0dp"
|
||||||
|
android:paddingBottom="0dp"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:inputType="textMultiLine"
|
||||||
|
android:maxLines="2"
|
||||||
android:hint="@string/activity_restore_seed_edit_text_hint" />
|
android:hint="@string/activity_restore_seed_edit_text_hint" />
|
||||||
|
|
||||||
<View
|
<View
|
||||||
|
@ -34,10 +34,15 @@
|
|||||||
style="@style/SmallSessionEditText"
|
style="@style/SmallSessionEditText"
|
||||||
android:id="@+id/mnemonicEditText"
|
android:id="@+id/mnemonicEditText"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="80dp"
|
||||||
android:layout_marginLeft="@dimen/very_large_spacing"
|
android:layout_marginLeft="@dimen/very_large_spacing"
|
||||||
android:layout_marginTop="10dp"
|
android:layout_marginTop="10dp"
|
||||||
android:layout_marginRight="@dimen/very_large_spacing"
|
android:layout_marginRight="@dimen/very_large_spacing"
|
||||||
|
android:paddingTop="0dp"
|
||||||
|
android:paddingBottom="0dp"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:inputType="textMultiLine"
|
||||||
|
android:maxLines="2"
|
||||||
android:hint="@string/activity_restore_seed_edit_text_hint" />
|
android:hint="@string/activity_restore_seed_edit_text_hint" />
|
||||||
|
|
||||||
<View
|
<View
|
||||||
|
@ -32,7 +32,7 @@
|
|||||||
app:minHeight="100dp"
|
app:minHeight="100dp"
|
||||||
app:maxHeight="300dp"/>
|
app:maxHeight="300dp"/>
|
||||||
|
|
||||||
<org.thoughtcrime.securesms.components.AudioView
|
<org.thoughtcrime.securesms.loki.views.MessageAudioView
|
||||||
android:id="@+id/attachment_audio"
|
android:id="@+id/attachment_audio"
|
||||||
android:layout_width="210dp"
|
android:layout_width="210dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
@ -41,7 +41,8 @@
|
|||||||
android:paddingBottom="15dp"
|
android:paddingBottom="15dp"
|
||||||
app:widgetBackground="?conversation_item_bubble_background"
|
app:widgetBackground="?conversation_item_bubble_background"
|
||||||
app:foregroundTintColor="?android:colorControlNormal"
|
app:foregroundTintColor="?android:colorControlNormal"
|
||||||
app:backgroundTintColor="?conversation_item_bubble_background"/>
|
app:waveformFillColor="?conversation_item_audio_seek_bar_color_outgoing"
|
||||||
|
app:waveformBackgroundColor="?conversation_item_audio_seek_bar_background_color"/>
|
||||||
|
|
||||||
<org.thoughtcrime.securesms.components.DocumentView
|
<org.thoughtcrime.securesms.components.DocumentView
|
||||||
android:id="@+id/attachment_document"
|
android:id="@+id/attachment_document"
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<org.thoughtcrime.securesms.components.AudioView
|
<org.thoughtcrime.securesms.loki.views.MessageAudioView
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
@ -8,5 +8,6 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:visibility="gone"
|
android:visibility="gone"
|
||||||
app:foregroundTintColor="?android:colorControlNormal"
|
app:foregroundTintColor="?android:colorControlNormal"
|
||||||
app:backgroundTintColor="?message_received_background_color"
|
app:waveformFillColor="?conversation_item_audio_seek_bar_color_incoming"
|
||||||
|
app:waveformBackgroundColor="?conversation_item_audio_seek_bar_background_color"
|
||||||
tools:visibility="visible"/>
|
tools:visibility="visible"/>
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<org.thoughtcrime.securesms.components.AudioView
|
<org.thoughtcrime.securesms.loki.views.MessageAudioView
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
android:id="@+id/audio_view"
|
android:id="@+id/audio_view"
|
||||||
android:layout_width="210dp"
|
android:layout_width="210dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
app:foregroundTintColor="?android:colorControlNormal"
|
app:foregroundTintColor="?android:colorControlNormal"
|
||||||
app:backgroundTintColor="?message_sent_background_color"
|
app:waveformFillColor="?conversation_item_audio_seek_bar_color_outgoing"
|
||||||
|
app:waveformBackgroundColor="?conversation_item_audio_seek_bar_background_color"
|
||||||
android:visibility="gone"/>
|
android:visibility="gone"/>
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
<merge xmlns:android="http://schemas.android.com/apk/res/android"
|
<merge xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
tools:context="org.thoughtcrime.securesms.components.AudioView">
|
tools:context="org.thoughtcrime.securesms.loki.views.MessageAudioView">
|
||||||
|
|
||||||
<LinearLayout android:id="@+id/audio_widget_container"
|
<LinearLayout android:id="@+id/audio_widget_container"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
@ -15,25 +15,28 @@
|
|||||||
|
|
||||||
<org.thoughtcrime.securesms.components.AnimatingToggle
|
<org.thoughtcrime.securesms.components.AnimatingToggle
|
||||||
android:id="@+id/control_toggle"
|
android:id="@+id/control_toggle"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="48dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="48dp"
|
||||||
android:layout_gravity="center"
|
android:layout_gravity="center"
|
||||||
android:gravity="center">
|
android:gravity="center">
|
||||||
|
|
||||||
<com.pnikosis.materialishprogress.ProgressWheel
|
<ProgressBar
|
||||||
android:id="@+id/download_progress"
|
android:id="@+id/download_progress"
|
||||||
android:layout_width="48dp"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="48dp"
|
android:layout_height="match_parent"
|
||||||
|
android:padding="8dp"
|
||||||
|
android:background="@drawable/circle_tintable_4dp_inset"
|
||||||
android:visibility="gone"
|
android:visibility="gone"
|
||||||
android:layout_gravity="center"
|
android:layout_gravity="center_vertical"
|
||||||
app:matProg_barColor="@color/white"
|
android:min="0"
|
||||||
app:matProg_linearProgress="true"
|
android:max="100"
|
||||||
app:matProg_spinSpeed="0.333"
|
tools:visibility="gone"
|
||||||
tools:visibility="gone"/>
|
tools:backgroundTint="@android:color/black"
|
||||||
|
tools:indeterminateTint="@android:color/white"/>
|
||||||
|
|
||||||
<ImageView android:id="@+id/play"
|
<ImageView android:id="@+id/play"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="match_parent"
|
||||||
android:layout_gravity="center_vertical"
|
android:layout_gravity="center_vertical"
|
||||||
android:gravity="center_vertical"
|
android:gravity="center_vertical"
|
||||||
android:clickable="true"
|
android:clickable="true"
|
||||||
@ -45,8 +48,8 @@
|
|||||||
tools:visibility="visible"/>
|
tools:visibility="visible"/>
|
||||||
|
|
||||||
<ImageView android:id="@+id/pause"
|
<ImageView android:id="@+id/pause"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="match_parent"
|
||||||
android:layout_gravity="center_vertical"
|
android:layout_gravity="center_vertical"
|
||||||
android:gravity="center_vertical"
|
android:gravity="center_vertical"
|
||||||
android:clickable="true"
|
android:clickable="true"
|
||||||
@ -58,8 +61,8 @@
|
|||||||
tools:visibility="gone"/>
|
tools:visibility="gone"/>
|
||||||
|
|
||||||
<ImageView android:id="@+id/download"
|
<ImageView android:id="@+id/download"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="match_parent"
|
||||||
android:layout_gravity="center_vertical"
|
android:layout_gravity="center_vertical"
|
||||||
android:clickable="true"
|
android:clickable="true"
|
||||||
android:visibility="gone"
|
android:visibility="gone"
|
||||||
@ -70,27 +73,36 @@
|
|||||||
|
|
||||||
</org.thoughtcrime.securesms.components.AnimatingToggle>
|
</org.thoughtcrime.securesms.components.AnimatingToggle>
|
||||||
|
|
||||||
<SeekBar android:id="@+id/seek"
|
<org.thoughtcrime.securesms.loki.views.WaveformSeekBar
|
||||||
android:layout_width="fill_parent"
|
android:id="@+id/seek"
|
||||||
android:layout_height="wrap_content"
|
android:layout_width="0dp"
|
||||||
android:layout_gravity="center_vertical"/>
|
android:layout_height="38dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_gravity="center_vertical"
|
||||||
|
android:layout_marginStart="4dp"
|
||||||
|
android:layout_marginEnd="4dp"
|
||||||
|
app:bar_gravity="center"
|
||||||
|
app:bar_width="4dp"
|
||||||
|
app:bar_corner_radius="2dp"
|
||||||
|
app:bar_gap="1dp"
|
||||||
|
tools:progress="0.5"
|
||||||
|
tools:bar_background_color="#bbb"
|
||||||
|
tools:bar_progress_color="?colorPrimary"/>
|
||||||
|
|
||||||
|
<TextView android:id="@+id/total_duration"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center_vertical"
|
||||||
|
android:textAppearance="?android:attr/textAppearanceSmall"
|
||||||
|
android:textSize="@dimen/conversation_item_date_text_size"
|
||||||
|
android:fontFamily="sans-serif-light"
|
||||||
|
android:autoLink="none"
|
||||||
|
android:visibility="gone"
|
||||||
|
tools:text="0:05"
|
||||||
|
tools:visibility="visible"/>
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<TextView android:id="@+id/timestamp"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginStart="76dip"
|
|
||||||
android:textAppearance="?android:attr/textAppearanceSmall"
|
|
||||||
android:textColor="?conversation_item_sent_text_secondary_color"
|
|
||||||
android:textSize="@dimen/conversation_item_date_text_size"
|
|
||||||
android:fontFamily="sans-serif-light"
|
|
||||||
android:autoLink="none"
|
|
||||||
android:visibility="gone"
|
|
||||||
tools:text="00:15"
|
|
||||||
tools:visibility="visible"
|
|
||||||
/>
|
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
</merge>
|
</merge>
|
@ -27,6 +27,9 @@
|
|||||||
<item name="media_keyboard_button_color">@color/core_grey_60</item>
|
<item name="media_keyboard_button_color">@color/core_grey_60</item>
|
||||||
|
|
||||||
<item name="menu_info_icon">@drawable/ic_outline_info_24</item>
|
<item name="menu_info_icon">@drawable/ic_outline_info_24</item>
|
||||||
|
|
||||||
|
<item name="conversation_item_audio_seek_bar_color_incoming">@color/accent</item>
|
||||||
|
<item name="conversation_item_audio_seek_bar_color_outgoing">@color/white</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style name="Theme.Session.DayNight" parent="Theme.Session.Light">
|
<style name="Theme.Session.DayNight" parent="Theme.Session.Light">
|
||||||
|
@ -84,6 +84,9 @@
|
|||||||
<attr name="conversation_item_sticky_date_background" format="reference" />
|
<attr name="conversation_item_sticky_date_background" format="reference" />
|
||||||
<attr name="conversation_item_sticky_date_text_color" format="color" />
|
<attr name="conversation_item_sticky_date_text_color" format="color" />
|
||||||
<attr name="conversation_item_image_outline_color" format="color" />
|
<attr name="conversation_item_image_outline_color" format="color" />
|
||||||
|
<attr name="conversation_item_audio_seek_bar_color_incoming" format="reference|color" />
|
||||||
|
<attr name="conversation_item_audio_seek_bar_color_outgoing" format="reference|color" />
|
||||||
|
<attr name="conversation_item_audio_seek_bar_background_color" format="reference|color" />
|
||||||
|
|
||||||
<attr name="dialog_info_icon" format="reference" />
|
<attr name="dialog_info_icon" format="reference" />
|
||||||
<attr name="dialog_alert_icon" format="reference" />
|
<attr name="dialog_alert_icon" format="reference" />
|
||||||
@ -169,10 +172,11 @@
|
|||||||
<attr name="useSmallIcon" format="boolean" />
|
<attr name="useSmallIcon" format="boolean" />
|
||||||
</declare-styleable>
|
</declare-styleable>
|
||||||
|
|
||||||
<declare-styleable name="AudioView">
|
<declare-styleable name="MessageAudioView">
|
||||||
<attr name="widgetBackground" format="color"/>
|
<attr name="widgetBackground" format="color"/>
|
||||||
<attr name="foregroundTintColor" format="color" />
|
<attr name="foregroundTintColor" format="color" />
|
||||||
<attr name="backgroundTintColor" format="color" />
|
<attr name="waveformFillColor" format="reference|color" />
|
||||||
|
<attr name="waveformBackgroundColor" format="reference|color" />
|
||||||
</declare-styleable>
|
</declare-styleable>
|
||||||
|
|
||||||
<declare-styleable name="CircleColorImageView">
|
<declare-styleable name="CircleColorImageView">
|
||||||
@ -287,4 +291,20 @@
|
|||||||
<attr name="labeledEditText_background" format="color" />
|
<attr name="labeledEditText_background" format="color" />
|
||||||
<attr name="labeledEditText_textLayout" format="reference" />
|
<attr name="labeledEditText_textLayout" format="reference" />
|
||||||
</declare-styleable>
|
</declare-styleable>
|
||||||
|
|
||||||
|
<declare-styleable name="WaveformSeekBar">
|
||||||
|
<attr name="progress" format="float"/>
|
||||||
|
<attr name="bar_width" format="dimension"/>
|
||||||
|
<attr name="bar_gap" format="dimension"/>
|
||||||
|
<attr name="bar_min_height" format="dimension"/>
|
||||||
|
<attr name="bar_corner_radius" format="dimension"/>
|
||||||
|
<attr name="bar_background_color" format="color"/>
|
||||||
|
<attr name="bar_progress_color" format="color"/>
|
||||||
|
<!-- Corresponds to WaveformSeekBar.WaveGravity enum. -->
|
||||||
|
<attr name="bar_gravity" format="enum">
|
||||||
|
<enum name="top" value="1" />
|
||||||
|
<enum name="center" value="2" />
|
||||||
|
<enum name="bottom" value="3" />
|
||||||
|
</attr>
|
||||||
|
</declare-styleable>
|
||||||
</resources>
|
</resources>
|
||||||
|
@ -87,6 +87,10 @@
|
|||||||
|
|
||||||
<item name="quick_camera_icon">@drawable/ic_baseline_photo_camera_24</item>
|
<item name="quick_camera_icon">@drawable/ic_baseline_photo_camera_24</item>
|
||||||
<item name="quick_mic_icon">@drawable/ic_baseline_mic_24</item>
|
<item name="quick_mic_icon">@drawable/ic_baseline_mic_24</item>
|
||||||
|
|
||||||
|
<item name="conversation_item_audio_seek_bar_color_incoming">@color/accent</item>
|
||||||
|
<item name="conversation_item_audio_seek_bar_color_outgoing">@color/accent</item>
|
||||||
|
<item name="conversation_item_audio_seek_bar_background_color">@color/text</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<!-- This should be the default theme for the application. -->
|
<!-- This should be the default theme for the application. -->
|
||||||
|
@ -42,6 +42,7 @@ import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
|
|||||||
import org.thoughtcrime.securesms.crypto.storage.TextSecureSessionStore;
|
import org.thoughtcrime.securesms.crypto.storage.TextSecureSessionStore;
|
||||||
import org.thoughtcrime.securesms.database.Address;
|
import org.thoughtcrime.securesms.database.Address;
|
||||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||||
|
import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||||
import org.thoughtcrime.securesms.dependencies.AxolotlStorageModule;
|
import org.thoughtcrime.securesms.dependencies.AxolotlStorageModule;
|
||||||
import org.thoughtcrime.securesms.dependencies.InjectableType;
|
import org.thoughtcrime.securesms.dependencies.InjectableType;
|
||||||
import org.thoughtcrime.securesms.dependencies.SignalCommunicationModule;
|
import org.thoughtcrime.securesms.dependencies.SignalCommunicationModule;
|
||||||
@ -305,7 +306,8 @@ public class ApplicationContext extends MultiDexApplication implements Dependenc
|
|||||||
byte[] userPrivateKey = IdentityKeyUtil.getIdentityKeyPair(this).getPrivateKey().serialize();
|
byte[] userPrivateKey = IdentityKeyUtil.getIdentityKeyPair(this).getPrivateKey().serialize();
|
||||||
LokiAPIDatabase apiDB = DatabaseFactory.getLokiAPIDatabase(this);
|
LokiAPIDatabase apiDB = DatabaseFactory.getLokiAPIDatabase(this);
|
||||||
LokiUserDatabase userDB = DatabaseFactory.getLokiUserDatabase(this);
|
LokiUserDatabase userDB = DatabaseFactory.getLokiUserDatabase(this);
|
||||||
publicChatAPI = new PublicChatAPI(userPublicKey, userPrivateKey, apiDB, userDB);
|
GroupDatabase groupDB = DatabaseFactory.getGroupDatabase(this);
|
||||||
|
publicChatAPI = new PublicChatAPI(userPublicKey, userPrivateKey, apiDB, userDB, groupDB);
|
||||||
return publicChatAPI;
|
return publicChatAPI;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,23 @@
|
|||||||
|
package org.thoughtcrime.securesms.attachments
|
||||||
|
|
||||||
|
data class DatabaseAttachmentAudioExtras(
|
||||||
|
val attachmentId: AttachmentId,
|
||||||
|
/** Small amount of normalized audio byte samples to visualise the content (e.g. draw waveform). */
|
||||||
|
val visualSamples: ByteArray,
|
||||||
|
/** Duration of the audio track in milliseconds. May be [DURATION_UNDEFINED] when it is not known. */
|
||||||
|
val durationMs: Long = DURATION_UNDEFINED) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val DURATION_UNDEFINED = -1L
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
return other != null &&
|
||||||
|
other is DatabaseAttachmentAudioExtras &&
|
||||||
|
other.attachmentId == attachmentId
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
return attachmentId.hashCode()
|
||||||
|
}
|
||||||
|
}
|
@ -32,6 +32,7 @@ import com.google.android.exoplayer2.source.MediaSource;
|
|||||||
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector;
|
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector;
|
||||||
import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory;
|
import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory;
|
||||||
|
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
import org.thoughtcrime.securesms.attachments.AttachmentServer;
|
import org.thoughtcrime.securesms.attachments.AttachmentServer;
|
||||||
import org.thoughtcrime.securesms.logging.Log;
|
import org.thoughtcrime.securesms.logging.Log;
|
||||||
import org.thoughtcrime.securesms.mms.AudioSlide;
|
import org.thoughtcrime.securesms.mms.AudioSlide;
|
||||||
@ -150,7 +151,11 @@ public class AudioSlidePlayer implements SensorEventListener {
|
|||||||
|
|
||||||
case Player.STATE_ENDED:
|
case Player.STATE_ENDED:
|
||||||
Log.i(TAG, "onComplete");
|
Log.i(TAG, "onComplete");
|
||||||
|
|
||||||
|
long millis = mediaPlayer.getDuration();
|
||||||
|
|
||||||
synchronized (AudioSlidePlayer.this) {
|
synchronized (AudioSlidePlayer.this) {
|
||||||
|
mediaPlayer.release();
|
||||||
mediaPlayer = null;
|
mediaPlayer = null;
|
||||||
|
|
||||||
if (audioAttachmentServer != null) {
|
if (audioAttachmentServer != null) {
|
||||||
@ -167,6 +172,7 @@ public class AudioSlidePlayer implements SensorEventListener {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
notifyOnProgress(1.0, millis);
|
||||||
notifyOnStop();
|
notifyOnStop();
|
||||||
progressEventHandler.removeMessages(0);
|
progressEventHandler.removeMessages(0);
|
||||||
}
|
}
|
||||||
@ -233,6 +239,20 @@ public class AudioSlidePlayer implements SensorEventListener {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public synchronized boolean isReady() {
|
||||||
|
if (mediaPlayer == null) return false;
|
||||||
|
|
||||||
|
return mediaPlayer.getPlaybackState() == Player.STATE_READY && mediaPlayer.getPlayWhenReady();
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void seekTo(double progress) throws IOException {
|
||||||
|
if (mediaPlayer == null || !isReady()) {
|
||||||
|
play(progress);
|
||||||
|
} else {
|
||||||
|
mediaPlayer.seekTo((long) (mediaPlayer.getDuration() * progress));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void setListener(@NonNull Listener listener) {
|
public void setListener(@NonNull Listener listener) {
|
||||||
this.listener = new WeakReference<>(listener);
|
this.listener = new WeakReference<>(listener);
|
||||||
|
|
||||||
@ -256,30 +276,15 @@ public class AudioSlidePlayer implements SensorEventListener {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void notifyOnStart() {
|
private void notifyOnStart() {
|
||||||
Util.runOnMain(new Runnable() {
|
Util.runOnMain(() -> getListener().onPlayerStart(AudioSlidePlayer.this));
|
||||||
@Override
|
|
||||||
public void run() {
|
|
||||||
getListener().onStart();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void notifyOnStop() {
|
private void notifyOnStop() {
|
||||||
Util.runOnMain(new Runnable() {
|
Util.runOnMain(() -> getListener().onPlayerStop(AudioSlidePlayer.this));
|
||||||
@Override
|
|
||||||
public void run() {
|
|
||||||
getListener().onStop();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void notifyOnProgress(final double progress, final long millis) {
|
private void notifyOnProgress(final double progress, final long millis) {
|
||||||
Util.runOnMain(new Runnable() {
|
Util.runOnMain(() -> getListener().onPlayerProgress(AudioSlidePlayer.this, progress, millis));
|
||||||
@Override
|
|
||||||
public void run() {
|
|
||||||
getListener().onProgress(progress, millis);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private @NonNull Listener getListener() {
|
private @NonNull Listener getListener() {
|
||||||
@ -288,11 +293,11 @@ public class AudioSlidePlayer implements SensorEventListener {
|
|||||||
if (listener != null) return listener;
|
if (listener != null) return listener;
|
||||||
else return new Listener() {
|
else return new Listener() {
|
||||||
@Override
|
@Override
|
||||||
public void onStart() {}
|
public void onPlayerStart(@NotNull AudioSlidePlayer player) { }
|
||||||
@Override
|
@Override
|
||||||
public void onStop() {}
|
public void onPlayerStop(@NotNull AudioSlidePlayer player) { }
|
||||||
@Override
|
@Override
|
||||||
public void onProgress(double progress, long millis) {}
|
public void onPlayerProgress(@NotNull AudioSlidePlayer player, double progress, long millis) { }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -355,9 +360,9 @@ public class AudioSlidePlayer implements SensorEventListener {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public interface Listener {
|
public interface Listener {
|
||||||
void onStart();
|
void onPlayerStart(@NonNull AudioSlidePlayer player);
|
||||||
void onStop();
|
void onPlayerStop(@NonNull AudioSlidePlayer player);
|
||||||
void onProgress(double progress, long millis);
|
void onPlayerProgress(@NonNull AudioSlidePlayer player, double progress, long millis);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static class ProgressEventHandler extends Handler {
|
private static class ProgressEventHandler extends Handler {
|
||||||
|
@ -1,330 +0,0 @@
|
|||||||
package org.thoughtcrime.securesms.components;
|
|
||||||
|
|
||||||
import android.annotation.TargetApi;
|
|
||||||
import android.content.Context;
|
|
||||||
import android.content.res.ColorStateList;
|
|
||||||
import android.content.res.TypedArray;
|
|
||||||
import android.graphics.Color;
|
|
||||||
import android.graphics.PorterDuff;
|
|
||||||
import android.graphics.drawable.AnimatedVectorDrawable;
|
|
||||||
import android.os.Build;
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
import android.util.AttributeSet;
|
|
||||||
import android.view.MotionEvent;
|
|
||||||
import android.view.View;
|
|
||||||
import android.view.ViewGroup;
|
|
||||||
import android.widget.FrameLayout;
|
|
||||||
import android.widget.ImageView;
|
|
||||||
import android.widget.SeekBar;
|
|
||||||
import android.widget.TextView;
|
|
||||||
|
|
||||||
import com.pnikosis.materialishprogress.ProgressWheel;
|
|
||||||
|
|
||||||
import org.greenrobot.eventbus.EventBus;
|
|
||||||
import org.greenrobot.eventbus.Subscribe;
|
|
||||||
import org.greenrobot.eventbus.ThreadMode;
|
|
||||||
import org.thoughtcrime.securesms.audio.AudioSlidePlayer;
|
|
||||||
import org.thoughtcrime.securesms.database.AttachmentDatabase;
|
|
||||||
import org.thoughtcrime.securesms.events.PartProgressEvent;
|
|
||||||
import org.thoughtcrime.securesms.logging.Log;
|
|
||||||
import org.thoughtcrime.securesms.mms.AudioSlide;
|
|
||||||
import org.thoughtcrime.securesms.mms.SlideClickListener;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
|
|
||||||
import network.loki.messenger.R;
|
|
||||||
|
|
||||||
|
|
||||||
public class AudioView extends FrameLayout implements AudioSlidePlayer.Listener {
|
|
||||||
|
|
||||||
private static final String TAG = AudioView.class.getSimpleName();
|
|
||||||
|
|
||||||
private final @NonNull AnimatingToggle controlToggle;
|
|
||||||
private final @NonNull ViewGroup container;
|
|
||||||
private final @NonNull ImageView playButton;
|
|
||||||
private final @NonNull ImageView pauseButton;
|
|
||||||
private final @NonNull ImageView downloadButton;
|
|
||||||
private final @NonNull ProgressWheel downloadProgress;
|
|
||||||
private final @NonNull SeekBar seekBar;
|
|
||||||
private final @NonNull TextView timestamp;
|
|
||||||
|
|
||||||
private @Nullable SlideClickListener downloadListener;
|
|
||||||
private @Nullable AudioSlidePlayer audioSlidePlayer;
|
|
||||||
private int backwardsCounter;
|
|
||||||
|
|
||||||
public AudioView(Context context) {
|
|
||||||
this(context, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public AudioView(Context context, AttributeSet attrs) {
|
|
||||||
this(context, attrs, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
public AudioView(Context context, AttributeSet attrs, int defStyleAttr) {
|
|
||||||
super(context, attrs, defStyleAttr);
|
|
||||||
inflate(context, R.layout.audio_view, this);
|
|
||||||
|
|
||||||
this.container = (ViewGroup) findViewById(R.id.audio_widget_container);
|
|
||||||
this.controlToggle = (AnimatingToggle) findViewById(R.id.control_toggle);
|
|
||||||
this.playButton = (ImageView) findViewById(R.id.play);
|
|
||||||
this.pauseButton = (ImageView) findViewById(R.id.pause);
|
|
||||||
this.downloadButton = (ImageView) findViewById(R.id.download);
|
|
||||||
this.downloadProgress = (ProgressWheel) findViewById(R.id.download_progress);
|
|
||||||
this.seekBar = (SeekBar) findViewById(R.id.seek);
|
|
||||||
this.timestamp = (TextView) findViewById(R.id.timestamp);
|
|
||||||
|
|
||||||
this.playButton.setOnClickListener(new PlayClickedListener());
|
|
||||||
this.pauseButton.setOnClickListener(new PauseClickedListener());
|
|
||||||
this.seekBar.setOnSeekBarChangeListener(new SeekBarModifiedListener());
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
|
||||||
this.playButton.setImageDrawable(context.getDrawable(R.drawable.play_icon));
|
|
||||||
this.pauseButton.setImageDrawable(context.getDrawable(R.drawable.pause_icon));
|
|
||||||
this.playButton.setBackground(context.getDrawable(R.drawable.ic_circle_fill_white_48dp));
|
|
||||||
this.pauseButton.setBackground(context.getDrawable(R.drawable.ic_circle_fill_white_48dp));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attrs != null) {
|
|
||||||
TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.AudioView, 0, 0);
|
|
||||||
setTint(typedArray.getColor(R.styleable.AudioView_foregroundTintColor, Color.WHITE),
|
|
||||||
typedArray.getColor(R.styleable.AudioView_backgroundTintColor, Color.WHITE));
|
|
||||||
container.setBackgroundColor(typedArray.getColor(R.styleable.AudioView_widgetBackground, Color.TRANSPARENT));
|
|
||||||
typedArray.recycle();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onAttachedToWindow() {
|
|
||||||
super.onAttachedToWindow();
|
|
||||||
if (!EventBus.getDefault().isRegistered(this)) EventBus.getDefault().register(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onDetachedFromWindow() {
|
|
||||||
super.onDetachedFromWindow();
|
|
||||||
EventBus.getDefault().unregister(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setAudio(final @NonNull AudioSlide audio,
|
|
||||||
final boolean showControls)
|
|
||||||
{
|
|
||||||
|
|
||||||
if (showControls && audio.isPendingDownload()) {
|
|
||||||
controlToggle.displayQuick(downloadButton);
|
|
||||||
seekBar.setEnabled(false);
|
|
||||||
downloadButton.setOnClickListener(new DownloadClickedListener(audio));
|
|
||||||
if (downloadProgress.isSpinning()) downloadProgress.stopSpinning();
|
|
||||||
} else if (showControls && audio.getTransferState() == AttachmentDatabase.TRANSFER_PROGRESS_STARTED) {
|
|
||||||
controlToggle.displayQuick(downloadProgress);
|
|
||||||
seekBar.setEnabled(false);
|
|
||||||
downloadProgress.spin();
|
|
||||||
} else {
|
|
||||||
controlToggle.displayQuick(playButton);
|
|
||||||
seekBar.setEnabled(true);
|
|
||||||
if (downloadProgress.isSpinning()) downloadProgress.stopSpinning();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.audioSlidePlayer = AudioSlidePlayer.createFor(getContext(), audio, this);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void cleanup() {
|
|
||||||
if (this.audioSlidePlayer != null && pauseButton.getVisibility() == View.VISIBLE) {
|
|
||||||
this.audioSlidePlayer.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setDownloadClickListener(@Nullable SlideClickListener listener) {
|
|
||||||
this.downloadListener = listener;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onStart() {
|
|
||||||
if (this.pauseButton.getVisibility() != View.VISIBLE) {
|
|
||||||
togglePlayToPause();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onStop() {
|
|
||||||
if (this.playButton.getVisibility() != View.VISIBLE) {
|
|
||||||
togglePauseToPlay();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (seekBar.getProgress() + 5 >= seekBar.getMax()) {
|
|
||||||
backwardsCounter = 4;
|
|
||||||
onProgress(0.0, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void setFocusable(boolean focusable) {
|
|
||||||
super.setFocusable(focusable);
|
|
||||||
this.playButton.setFocusable(focusable);
|
|
||||||
this.pauseButton.setFocusable(focusable);
|
|
||||||
this.seekBar.setFocusable(focusable);
|
|
||||||
this.seekBar.setFocusableInTouchMode(focusable);
|
|
||||||
this.downloadButton.setFocusable(focusable);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void setClickable(boolean clickable) {
|
|
||||||
super.setClickable(clickable);
|
|
||||||
this.playButton.setClickable(clickable);
|
|
||||||
this.pauseButton.setClickable(clickable);
|
|
||||||
this.seekBar.setClickable(clickable);
|
|
||||||
this.seekBar.setOnTouchListener(clickable ? null : new TouchIgnoringListener());
|
|
||||||
this.downloadButton.setClickable(clickable);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void setEnabled(boolean enabled) {
|
|
||||||
super.setEnabled(enabled);
|
|
||||||
this.playButton.setEnabled(enabled);
|
|
||||||
this.pauseButton.setEnabled(enabled);
|
|
||||||
this.seekBar.setEnabled(enabled);
|
|
||||||
this.downloadButton.setEnabled(enabled);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onProgress(double progress, long millis) {
|
|
||||||
int seekProgress = (int)Math.floor(progress * this.seekBar.getMax());
|
|
||||||
|
|
||||||
if (seekProgress > seekBar.getProgress() || backwardsCounter > 3) {
|
|
||||||
backwardsCounter = 0;
|
|
||||||
this.seekBar.setProgress(seekProgress);
|
|
||||||
this.timestamp.setText(String.format("%02d:%02d",
|
|
||||||
TimeUnit.MILLISECONDS.toMinutes(millis),
|
|
||||||
TimeUnit.MILLISECONDS.toSeconds(millis)));
|
|
||||||
} else {
|
|
||||||
backwardsCounter++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setTint(int foregroundTint, int backgroundTint) {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
|
||||||
this.playButton.setBackgroundTintList(ColorStateList.valueOf(foregroundTint));
|
|
||||||
this.playButton.setImageTintList(ColorStateList.valueOf(backgroundTint));
|
|
||||||
this.pauseButton.setBackgroundTintList(ColorStateList.valueOf(foregroundTint));
|
|
||||||
this.pauseButton.setImageTintList(ColorStateList.valueOf(backgroundTint));
|
|
||||||
} else {
|
|
||||||
this.playButton.setColorFilter(foregroundTint, PorterDuff.Mode.SRC_IN);
|
|
||||||
this.pauseButton.setColorFilter(foregroundTint, PorterDuff.Mode.SRC_IN);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.downloadButton.setColorFilter(foregroundTint, PorterDuff.Mode.SRC_IN);
|
|
||||||
this.downloadProgress.setBarColor(foregroundTint);
|
|
||||||
|
|
||||||
this.timestamp.setTextColor(foregroundTint);
|
|
||||||
this.seekBar.getProgressDrawable().setColorFilter(foregroundTint, PorterDuff.Mode.SRC_IN);
|
|
||||||
this.seekBar.getThumb().setColorFilter(foregroundTint, PorterDuff.Mode.SRC_IN);
|
|
||||||
}
|
|
||||||
|
|
||||||
private double getProgress() {
|
|
||||||
if (this.seekBar.getProgress() <= 0 || this.seekBar.getMax() <= 0) {
|
|
||||||
return 0;
|
|
||||||
} else {
|
|
||||||
return (double)this.seekBar.getProgress() / (double)this.seekBar.getMax();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void togglePlayToPause() {
|
|
||||||
controlToggle.displayQuick(pauseButton);
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
|
||||||
AnimatedVectorDrawable playToPauseDrawable = (AnimatedVectorDrawable)getContext().getDrawable(R.drawable.play_to_pause_animation);
|
|
||||||
pauseButton.setImageDrawable(playToPauseDrawable);
|
|
||||||
playToPauseDrawable.start();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void togglePauseToPlay() {
|
|
||||||
controlToggle.displayQuick(playButton);
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
|
||||||
AnimatedVectorDrawable pauseToPlayDrawable = (AnimatedVectorDrawable)getContext().getDrawable(R.drawable.pause_to_play_animation);
|
|
||||||
playButton.setImageDrawable(pauseToPlayDrawable);
|
|
||||||
pauseToPlayDrawable.start();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class PlayClickedListener implements View.OnClickListener {
|
|
||||||
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
|
|
||||||
@Override
|
|
||||||
public void onClick(View v) {
|
|
||||||
try {
|
|
||||||
Log.d(TAG, "playbutton onClick");
|
|
||||||
if (audioSlidePlayer != null) {
|
|
||||||
togglePlayToPause();
|
|
||||||
audioSlidePlayer.play(getProgress());
|
|
||||||
}
|
|
||||||
} catch (IOException e) {
|
|
||||||
Log.w(TAG, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class PauseClickedListener implements View.OnClickListener {
|
|
||||||
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
|
|
||||||
@Override
|
|
||||||
public void onClick(View v) {
|
|
||||||
Log.d(TAG, "pausebutton onClick");
|
|
||||||
if (audioSlidePlayer != null) {
|
|
||||||
togglePauseToPlay();
|
|
||||||
audioSlidePlayer.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class DownloadClickedListener implements View.OnClickListener {
|
|
||||||
private final @NonNull AudioSlide slide;
|
|
||||||
|
|
||||||
private DownloadClickedListener(@NonNull AudioSlide slide) {
|
|
||||||
this.slide = slide;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onClick(View v) {
|
|
||||||
if (downloadListener != null) downloadListener.onClick(v, slide);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class SeekBarModifiedListener implements SeekBar.OnSeekBarChangeListener {
|
|
||||||
@Override
|
|
||||||
public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public synchronized void onStartTrackingTouch(SeekBar seekBar) {
|
|
||||||
if (audioSlidePlayer != null && pauseButton.getVisibility() == View.VISIBLE) {
|
|
||||||
audioSlidePlayer.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public synchronized void onStopTrackingTouch(SeekBar seekBar) {
|
|
||||||
try {
|
|
||||||
if (audioSlidePlayer != null && pauseButton.getVisibility() == View.VISIBLE) {
|
|
||||||
audioSlidePlayer.play(getProgress());
|
|
||||||
}
|
|
||||||
} catch (IOException e) {
|
|
||||||
Log.w(TAG, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static class TouchIgnoringListener implements OnTouchListener {
|
|
||||||
@Override
|
|
||||||
public boolean onTouch(View v, MotionEvent event) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Subscribe(sticky = true, threadMode = ThreadMode.MAIN)
|
|
||||||
public void onEventAsync(final PartProgressEvent event) {
|
|
||||||
if (audioSlidePlayer != null && event.attachment.equals(audioSlidePlayer.getAudioSlide().asAttachment())) {
|
|
||||||
downloadProgress.setInstantProgress(((float) event.progress) / event.total);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -213,6 +213,7 @@ import org.thoughtcrime.securesms.util.DateUtils;
|
|||||||
import org.thoughtcrime.securesms.util.Dialogs;
|
import org.thoughtcrime.securesms.util.Dialogs;
|
||||||
import org.thoughtcrime.securesms.util.DynamicLanguage;
|
import org.thoughtcrime.securesms.util.DynamicLanguage;
|
||||||
import org.thoughtcrime.securesms.util.ExpirationUtil;
|
import org.thoughtcrime.securesms.util.ExpirationUtil;
|
||||||
|
import org.thoughtcrime.securesms.util.GroupUtil;
|
||||||
import org.thoughtcrime.securesms.util.IdentityUtil;
|
import org.thoughtcrime.securesms.util.IdentityUtil;
|
||||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||||
import org.thoughtcrime.securesms.util.ServiceUtil;
|
import org.thoughtcrime.securesms.util.ServiceUtil;
|
||||||
@ -227,6 +228,7 @@ import org.thoughtcrime.securesms.util.views.Stub;
|
|||||||
import org.whispersystems.libsignal.InvalidMessageException;
|
import org.whispersystems.libsignal.InvalidMessageException;
|
||||||
import org.whispersystems.libsignal.util.guava.Optional;
|
import org.whispersystems.libsignal.util.guava.Optional;
|
||||||
import org.whispersystems.signalservice.loki.api.opengroups.PublicChat;
|
import org.whispersystems.signalservice.loki.api.opengroups.PublicChat;
|
||||||
|
import org.whispersystems.signalservice.loki.api.opengroups.PublicChatAPI;
|
||||||
import org.whispersystems.signalservice.loki.protocol.mentions.Mention;
|
import org.whispersystems.signalservice.loki.protocol.mentions.Mention;
|
||||||
import org.whispersystems.signalservice.loki.protocol.mentions.MentionsManager;
|
import org.whispersystems.signalservice.loki.protocol.mentions.MentionsManager;
|
||||||
import org.whispersystems.signalservice.loki.protocol.meta.SessionMetaProtocol;
|
import org.whispersystems.signalservice.loki.protocol.meta.SessionMetaProtocol;
|
||||||
@ -458,7 +460,17 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
|||||||
|
|
||||||
PublicChat publicChat = DatabaseFactory.getLokiThreadDatabase(this).getPublicChat(threadId);
|
PublicChat publicChat = DatabaseFactory.getLokiThreadDatabase(this).getPublicChat(threadId);
|
||||||
if (publicChat != null) {
|
if (publicChat != null) {
|
||||||
ApplicationContext.getInstance(this).getPublicChatAPI().getChannelInfo(publicChat.getChannel(), publicChat.getServer()).success(displayName -> {
|
PublicChatAPI publicChatAPI = ApplicationContext.getInstance(this).getPublicChatAPI();
|
||||||
|
publicChatAPI.getChannelInfo(publicChat.getChannel(), publicChat.getServer()).success(info -> {
|
||||||
|
String groupId = GroupUtil.getEncodedOpenGroupId(publicChat.getId().getBytes());
|
||||||
|
|
||||||
|
publicChatAPI.updateProfileIfNeeded(
|
||||||
|
publicChat.getChannel(),
|
||||||
|
publicChat.getServer(),
|
||||||
|
groupId,
|
||||||
|
info,
|
||||||
|
false);
|
||||||
|
|
||||||
runOnUiThread(ConversationActivity.this::updateSubtitleTextView);
|
runOnUiThread(ConversationActivity.this::updateSubtitleTextView);
|
||||||
return Unit.INSTANCE;
|
return Unit.INSTANCE;
|
||||||
});
|
});
|
||||||
|
@ -60,7 +60,7 @@ import org.thoughtcrime.securesms.MediaPreviewActivity;
|
|||||||
import org.thoughtcrime.securesms.MessageDetailsActivity;
|
import org.thoughtcrime.securesms.MessageDetailsActivity;
|
||||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
|
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
|
||||||
import org.thoughtcrime.securesms.components.AlertView;
|
import org.thoughtcrime.securesms.components.AlertView;
|
||||||
import org.thoughtcrime.securesms.components.AudioView;
|
import org.thoughtcrime.securesms.loki.views.MessageAudioView;
|
||||||
import org.thoughtcrime.securesms.components.ConversationItemFooter;
|
import org.thoughtcrime.securesms.components.ConversationItemFooter;
|
||||||
import org.thoughtcrime.securesms.components.ConversationItemThumbnail;
|
import org.thoughtcrime.securesms.components.ConversationItemThumbnail;
|
||||||
import org.thoughtcrime.securesms.components.DocumentView;
|
import org.thoughtcrime.securesms.components.DocumentView;
|
||||||
@ -161,7 +161,7 @@ public class ConversationItem extends TapJackingProofLinearLayout
|
|||||||
private @NonNull Set<MessageRecord> batchSelected = new HashSet<>();
|
private @NonNull Set<MessageRecord> batchSelected = new HashSet<>();
|
||||||
private Recipient conversationRecipient;
|
private Recipient conversationRecipient;
|
||||||
private Stub<ConversationItemThumbnail> mediaThumbnailStub;
|
private Stub<ConversationItemThumbnail> mediaThumbnailStub;
|
||||||
private Stub<AudioView> audioViewStub;
|
private Stub<MessageAudioView> audioViewStub;
|
||||||
private Stub<DocumentView> documentViewStub;
|
private Stub<DocumentView> documentViewStub;
|
||||||
private Stub<SharedContactView> sharedContactStub;
|
private Stub<SharedContactView> sharedContactStub;
|
||||||
private Stub<LinkPreviewView> linkPreviewStub;
|
private Stub<LinkPreviewView> linkPreviewStub;
|
||||||
|
@ -51,6 +51,8 @@ public class IdentityKeyUtil {
|
|||||||
|
|
||||||
public static final String IDENTITY_PUBLIC_KEY_PREF = "pref_identity_public_v3";
|
public static final String IDENTITY_PUBLIC_KEY_PREF = "pref_identity_public_v3";
|
||||||
public static final String IDENTITY_PRIVATE_KEY_PREF = "pref_identity_private_v3";
|
public static final String IDENTITY_PRIVATE_KEY_PREF = "pref_identity_private_v3";
|
||||||
|
public static final String ED25519_PUBLIC_KEY = "pref_ed25519_public_key";
|
||||||
|
public static final String ED25519_SECRET_KEY = "pref_ed25519_secret_key";
|
||||||
|
|
||||||
public static final String lokiSeedKey = "loki_seed";
|
public static final String lokiSeedKey = "loki_seed";
|
||||||
|
|
||||||
|
@ -24,11 +24,12 @@ import android.graphics.Bitmap;
|
|||||||
import android.media.MediaMetadataRetriever;
|
import android.media.MediaMetadataRetriever;
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
|
import android.text.TextUtils;
|
||||||
|
import android.util.Pair;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import androidx.annotation.VisibleForTesting;
|
import androidx.annotation.VisibleForTesting;
|
||||||
import android.text.TextUtils;
|
|
||||||
import android.util.Pair;
|
|
||||||
|
|
||||||
import com.bumptech.glide.Glide;
|
import com.bumptech.glide.Glide;
|
||||||
|
|
||||||
@ -39,6 +40,7 @@ import org.json.JSONException;
|
|||||||
import org.thoughtcrime.securesms.attachments.Attachment;
|
import org.thoughtcrime.securesms.attachments.Attachment;
|
||||||
import org.thoughtcrime.securesms.attachments.AttachmentId;
|
import org.thoughtcrime.securesms.attachments.AttachmentId;
|
||||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
|
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
|
||||||
|
import org.thoughtcrime.securesms.attachments.DatabaseAttachmentAudioExtras;
|
||||||
import org.thoughtcrime.securesms.crypto.AttachmentSecret;
|
import org.thoughtcrime.securesms.crypto.AttachmentSecret;
|
||||||
import org.thoughtcrime.securesms.crypto.ClassicDecryptingPartInputStream;
|
import org.thoughtcrime.securesms.crypto.ClassicDecryptingPartInputStream;
|
||||||
import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream;
|
import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream;
|
||||||
@ -51,10 +53,10 @@ import org.thoughtcrime.securesms.mms.PartAuthority;
|
|||||||
import org.thoughtcrime.securesms.stickers.StickerLocator;
|
import org.thoughtcrime.securesms.stickers.StickerLocator;
|
||||||
import org.thoughtcrime.securesms.util.BitmapDecodingException;
|
import org.thoughtcrime.securesms.util.BitmapDecodingException;
|
||||||
import org.thoughtcrime.securesms.util.BitmapUtil;
|
import org.thoughtcrime.securesms.util.BitmapUtil;
|
||||||
|
import org.thoughtcrime.securesms.util.ExternalStorageUtil;
|
||||||
import org.thoughtcrime.securesms.util.JsonUtils;
|
import org.thoughtcrime.securesms.util.JsonUtils;
|
||||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||||
import org.thoughtcrime.securesms.util.MediaUtil.ThumbnailData;
|
import org.thoughtcrime.securesms.util.MediaUtil.ThumbnailData;
|
||||||
import org.thoughtcrime.securesms.util.ExternalStorageUtil;
|
|
||||||
import org.thoughtcrime.securesms.util.Util;
|
import org.thoughtcrime.securesms.util.Util;
|
||||||
import org.thoughtcrime.securesms.video.EncryptedMediaDataSource;
|
import org.thoughtcrime.securesms.video.EncryptedMediaDataSource;
|
||||||
|
|
||||||
@ -72,6 +74,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();
|
||||||
@ -105,6 +109,9 @@ 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.
|
||||||
|
static final String AUDIO_VISUAL_SAMPLES = "audio_visual_samples"; // Small amount of audio byte samples to visualise the content (e.g. draw waveform).
|
||||||
|
static final String AUDIO_DURATION = "audio_duration"; // Duration of the audio track in milliseconds.
|
||||||
|
|
||||||
public static final int TRANSFER_PROGRESS_DONE = 0;
|
public static final int TRANSFER_PROGRESS_DONE = 0;
|
||||||
public static final int TRANSFER_PROGRESS_STARTED = 1;
|
public static final int TRANSFER_PROGRESS_STARTED = 1;
|
||||||
@ -112,6 +119,7 @@ public class AttachmentDatabase extends Database {
|
|||||||
public static final int TRANSFER_PROGRESS_FAILED = 3;
|
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[] 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,
|
||||||
@ -121,6 +129,8 @@ public class AttachmentDatabase extends Database {
|
|||||||
QUOTE, DATA_RANDOM, THUMBNAIL_RANDOM, WIDTH, HEIGHT,
|
QUOTE, DATA_RANDOM, THUMBNAIL_RANDOM, WIDTH, HEIGHT,
|
||||||
CAPTION, STICKER_PACK_ID, STICKER_PACK_KEY, STICKER_ID, URL};
|
CAPTION, STICKER_PACK_ID, STICKER_PACK_KEY, STICKER_ID, URL};
|
||||||
|
|
||||||
|
private static final String[] PROJECTION_AUDIO_EXTRAS = new String[] {AUDIO_VISUAL_SAMPLES, AUDIO_DURATION};
|
||||||
|
|
||||||
public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ROW_ID + " INTEGER PRIMARY KEY, " +
|
public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ROW_ID + " INTEGER PRIMARY KEY, " +
|
||||||
MMS_ID + " INTEGER, " + "seq" + " INTEGER DEFAULT 0, " +
|
MMS_ID + " INTEGER, " + "seq" + " INTEGER DEFAULT 0, " +
|
||||||
CONTENT_TYPE + " TEXT, " + NAME + " TEXT, " + "chset" + " INTEGER, " +
|
CONTENT_TYPE + " TEXT, " + NAME + " TEXT, " + "chset" + " INTEGER, " +
|
||||||
@ -133,7 +143,8 @@ public class AttachmentDatabase extends Database {
|
|||||||
VOICE_NOTE + " INTEGER DEFAULT 0, " + DATA_RANDOM + " BLOB, " + THUMBNAIL_RANDOM + " BLOB, " +
|
VOICE_NOTE + " INTEGER DEFAULT 0, " + DATA_RANDOM + " BLOB, " + THUMBNAIL_RANDOM + " BLOB, " +
|
||||||
QUOTE + " INTEGER DEFAULT 0, " + WIDTH + " INTEGER DEFAULT 0, " + HEIGHT + " INTEGER DEFAULT 0, " +
|
QUOTE + " INTEGER DEFAULT 0, " + WIDTH + " INTEGER DEFAULT 0, " + HEIGHT + " INTEGER DEFAULT 0, " +
|
||||||
CAPTION + " TEXT DEFAULT NULL, " + URL + " TEXT, " + STICKER_PACK_ID + " TEXT DEFAULT NULL, " +
|
CAPTION + " TEXT DEFAULT NULL, " + URL + " TEXT, " + STICKER_PACK_ID + " TEXT DEFAULT NULL, " +
|
||||||
STICKER_PACK_KEY + " DEFAULT NULL, " + STICKER_ID + " INTEGER DEFAULT -1);";
|
STICKER_PACK_KEY + " DEFAULT NULL, " + STICKER_ID + " INTEGER DEFAULT -1," +
|
||||||
|
AUDIO_VISUAL_SAMPLES + " BLOB, " + AUDIO_DURATION + " INTEGER);";
|
||||||
|
|
||||||
public static final String[] CREATE_INDEXS = {
|
public static final String[] CREATE_INDEXS = {
|
||||||
"CREATE INDEX IF NOT EXISTS part_mms_id_index ON " + TABLE_NAME + " (" + MMS_ID + ");",
|
"CREATE INDEX IF NOT EXISTS part_mms_id_index ON " + TABLE_NAME + " (" + MMS_ID + ");",
|
||||||
@ -822,6 +833,49 @@ public class AttachmentDatabase extends Database {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the audio extra values associated with the attachment. Only "audio/*" mime type attachments are accepted.
|
||||||
|
* @return the related audio extras or null in case any of the audio extra columns are empty or the attachment is not an audio.
|
||||||
|
*/
|
||||||
|
@Synchronized
|
||||||
|
public @Nullable DatabaseAttachmentAudioExtras getAttachmentAudioExtras(@NonNull AttachmentId attachmentId) {
|
||||||
|
try (Cursor cursor = databaseHelper.getReadableDatabase()
|
||||||
|
// We expect all the audio extra values to be present (not null) or reject the whole record.
|
||||||
|
.query(TABLE_NAME,
|
||||||
|
PROJECTION_AUDIO_EXTRAS,
|
||||||
|
PART_ID_WHERE +
|
||||||
|
" AND " + AUDIO_VISUAL_SAMPLES + " IS NOT NULL" +
|
||||||
|
" AND " + AUDIO_DURATION + " IS NOT NULL" +
|
||||||
|
" AND " + PART_AUDIO_ONLY_WHERE,
|
||||||
|
attachmentId.toStrings(),
|
||||||
|
null, null, null, "1")) {
|
||||||
|
|
||||||
|
if (cursor == null || !cursor.moveToFirst()) return null;
|
||||||
|
|
||||||
|
byte[] audioSamples = cursor.getBlob(cursor.getColumnIndexOrThrow(AUDIO_VISUAL_SAMPLES));
|
||||||
|
long duration = cursor.getLong(cursor.getColumnIndexOrThrow(AUDIO_DURATION));
|
||||||
|
|
||||||
|
return new DatabaseAttachmentAudioExtras(attachmentId, audioSamples, duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates audio extra columns for the "audio/*" mime type attachments only.
|
||||||
|
* @return true if the update operation was successful.
|
||||||
|
*/
|
||||||
|
@Synchronized
|
||||||
|
public boolean setAttachmentAudioExtras(@NonNull DatabaseAttachmentAudioExtras extras) {
|
||||||
|
ContentValues values = new ContentValues();
|
||||||
|
values.put(AUDIO_VISUAL_SAMPLES, extras.getVisualSamples());
|
||||||
|
values.put(AUDIO_DURATION, extras.getDurationMs());
|
||||||
|
|
||||||
|
int alteredRows = databaseHelper.getWritableDatabase().update(TABLE_NAME,
|
||||||
|
values,
|
||||||
|
PART_ID_WHERE + " AND " + PART_AUDIO_ONLY_WHERE,
|
||||||
|
extras.getAttachmentId().toStrings());
|
||||||
|
|
||||||
|
return alteredRows > 0;
|
||||||
|
}
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
class ThumbnailFetchCallable implements Callable<InputStream> {
|
class ThumbnailFetchCallable implements Callable<InputStream> {
|
||||||
|
@ -6,9 +6,10 @@ import android.content.ContentValues;
|
|||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.database.Cursor;
|
import android.database.Cursor;
|
||||||
import android.graphics.Bitmap;
|
import android.graphics.Bitmap;
|
||||||
|
import android.text.TextUtils;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import android.text.TextUtils;
|
|
||||||
|
|
||||||
import com.annimon.stream.Stream;
|
import com.annimon.stream.Stream;
|
||||||
|
|
||||||
@ -21,6 +22,7 @@ import org.thoughtcrime.securesms.util.GroupUtil;
|
|||||||
import org.thoughtcrime.securesms.util.Util;
|
import org.thoughtcrime.securesms.util.Util;
|
||||||
import org.whispersystems.libsignal.util.guava.Optional;
|
import org.whispersystems.libsignal.util.guava.Optional;
|
||||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer;
|
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer;
|
||||||
|
import org.whispersystems.signalservice.loki.database.LokiOpenGroupDatabaseProtocol;
|
||||||
|
|
||||||
import java.io.Closeable;
|
import java.io.Closeable;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@ -29,7 +31,7 @@ import java.util.Collections;
|
|||||||
import java.util.LinkedList;
|
import java.util.LinkedList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public class GroupDatabase extends Database {
|
public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProtocol {
|
||||||
|
|
||||||
@SuppressWarnings("unused")
|
@SuppressWarnings("unused")
|
||||||
private static final String TAG = GroupDatabase.class.getSimpleName();
|
private static final String TAG = GroupDatabase.class.getSimpleName();
|
||||||
@ -240,35 +242,37 @@ public class GroupDatabase extends Database {
|
|||||||
notifyConversationListListeners();
|
notifyConversationListListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void updateTitle(String groupId, String title) {
|
@Override
|
||||||
|
public void updateTitle(String groupID, String newValue) {
|
||||||
ContentValues contentValues = new ContentValues();
|
ContentValues contentValues = new ContentValues();
|
||||||
contentValues.put(TITLE, title);
|
contentValues.put(TITLE, newValue);
|
||||||
databaseHelper.getWritableDatabase().update(TABLE_NAME, contentValues, GROUP_ID + " = ?",
|
databaseHelper.getWritableDatabase().update(TABLE_NAME, contentValues, GROUP_ID + " = ?",
|
||||||
new String[] {groupId});
|
new String[] {groupID});
|
||||||
|
|
||||||
Recipient recipient = Recipient.from(context, Address.fromSerialized(groupId), false);
|
Recipient recipient = Recipient.from(context, Address.fromSerialized(groupID), false);
|
||||||
recipient.setName(title);
|
recipient.setName(newValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void updateAvatar(String groupId, Bitmap avatar) {
|
public void updateProfilePicture(String groupID, Bitmap newValue) {
|
||||||
updateAvatar(groupId, BitmapUtil.toByteArray(avatar));
|
updateProfilePicture(groupID, BitmapUtil.toByteArray(newValue));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void updateAvatar(String groupId, byte[] avatar) {
|
@Override
|
||||||
|
public void updateProfilePicture(String groupID, byte[] newValue) {
|
||||||
long avatarId;
|
long avatarId;
|
||||||
|
|
||||||
if (avatar != null) avatarId = Math.abs(new SecureRandom().nextLong());
|
if (newValue != null) avatarId = Math.abs(new SecureRandom().nextLong());
|
||||||
else avatarId = 0;
|
else avatarId = 0;
|
||||||
|
|
||||||
|
|
||||||
ContentValues contentValues = new ContentValues(2);
|
ContentValues contentValues = new ContentValues(2);
|
||||||
contentValues.put(AVATAR, avatar);
|
contentValues.put(AVATAR, newValue);
|
||||||
contentValues.put(AVATAR_ID, avatarId);
|
contentValues.put(AVATAR_ID, avatarId);
|
||||||
|
|
||||||
databaseHelper.getWritableDatabase().update(TABLE_NAME, contentValues, GROUP_ID + " = ?",
|
databaseHelper.getWritableDatabase().update(TABLE_NAME, contentValues, GROUP_ID + " = ?",
|
||||||
new String[] {groupId});
|
new String[] {groupID});
|
||||||
|
|
||||||
Recipient.applyCached(Address.fromSerialized(groupId), recipient -> recipient.setGroupAvatarId(avatarId == 0 ? null : avatarId));
|
Recipient.applyCached(Address.fromSerialized(groupID), recipient -> recipient.setGroupAvatarId(avatarId == 0 ? null : avatarId));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void updateMembers(String groupId, List<Address> members) {
|
public void updateMembers(String groupId, List<Address> members) {
|
||||||
|
@ -91,8 +91,11 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
|||||||
private static final int lokiV12 = 33;
|
private static final int lokiV12 = 33;
|
||||||
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 lokiV16 = 37;
|
||||||
|
private static final int lokiV17 = 38;
|
||||||
|
|
||||||
private static final int DATABASE_VERSION = lokiV14_BACKUP_FILES; // Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes
|
private static final int DATABASE_VERSION = lokiV17;
|
||||||
private static final String DATABASE_NAME = "signal.db";
|
private static final String DATABASE_NAME = "signal.db";
|
||||||
|
|
||||||
private final Context context;
|
private final Context context;
|
||||||
@ -154,6 +157,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
|||||||
db.execSQL(LokiAPIDatabase.getCreateSessionRequestSentTimestampTableCommand());
|
db.execSQL(LokiAPIDatabase.getCreateSessionRequestSentTimestampTableCommand());
|
||||||
db.execSQL(LokiAPIDatabase.getCreateSessionRequestProcessedTimestampTableCommand());
|
db.execSQL(LokiAPIDatabase.getCreateSessionRequestProcessedTimestampTableCommand());
|
||||||
db.execSQL(LokiAPIDatabase.getCreateOpenGroupPublicKeyTableCommand());
|
db.execSQL(LokiAPIDatabase.getCreateOpenGroupPublicKeyTableCommand());
|
||||||
|
db.execSQL(LokiAPIDatabase.getCreateOpenGroupProfilePictureTableCommand());
|
||||||
db.execSQL(LokiPreKeyBundleDatabase.getCreateTableCommand());
|
db.execSQL(LokiPreKeyBundleDatabase.getCreateTableCommand());
|
||||||
db.execSQL(LokiPreKeyRecordDatabase.getCreateTableCommand());
|
db.execSQL(LokiPreKeyRecordDatabase.getCreateTableCommand());
|
||||||
db.execSQL(LokiMessageDatabase.getCreateMessageIDTableCommand());
|
db.execSQL(LokiMessageDatabase.getCreateMessageIDTableCommand());
|
||||||
@ -164,7 +168,8 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
|||||||
db.execSQL(LokiUserDatabase.getCreateDisplayNameTableCommand());
|
db.execSQL(LokiUserDatabase.getCreateDisplayNameTableCommand());
|
||||||
db.execSQL(LokiUserDatabase.getCreateServerDisplayNameTableCommand());
|
db.execSQL(LokiUserDatabase.getCreateServerDisplayNameTableCommand());
|
||||||
db.execSQL(LokiBackupFilesDatabase.getCreateTableCommand());
|
db.execSQL(LokiBackupFilesDatabase.getCreateTableCommand());
|
||||||
db.execSQL(SharedSenderKeysDatabase.getCreateClosedGroupRatchetTableCommand());
|
db.execSQL(SharedSenderKeysDatabase.getCreateOldClosedGroupRatchetTableCommand());
|
||||||
|
db.execSQL(SharedSenderKeysDatabase.getCreateCurrentClosedGroupRatchetTableCommand());
|
||||||
db.execSQL(SharedSenderKeysDatabase.getCreateClosedGroupPrivateKeyTableCommand());
|
db.execSQL(SharedSenderKeysDatabase.getCreateClosedGroupPrivateKeyTableCommand());
|
||||||
|
|
||||||
executeStatements(db, SmsDatabase.CREATE_INDEXS);
|
executeStatements(db, SmsDatabase.CREATE_INDEXS);
|
||||||
@ -614,7 +619,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
|||||||
|
|
||||||
if (oldVersion < lokiV12) {
|
if (oldVersion < lokiV12) {
|
||||||
db.execSQL(LokiAPIDatabase.getCreateLastMessageHashValueTable2Command());
|
db.execSQL(LokiAPIDatabase.getCreateLastMessageHashValueTable2Command());
|
||||||
db.execSQL(SharedSenderKeysDatabase.getCreateClosedGroupRatchetTableCommand());
|
db.execSQL(SharedSenderKeysDatabase.getCreateCurrentClosedGroupRatchetTableCommand());
|
||||||
db.execSQL(SharedSenderKeysDatabase.getCreateClosedGroupPrivateKeyTableCommand());
|
db.execSQL(SharedSenderKeysDatabase.getCreateClosedGroupPrivateKeyTableCommand());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -626,6 +631,19 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
|||||||
db.execSQL(LokiBackupFilesDatabase.getCreateTableCommand());
|
db.execSQL(LokiBackupFilesDatabase.getCreateTableCommand());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (oldVersion < lokiV15) {
|
||||||
|
db.execSQL(SharedSenderKeysDatabase.getCreateOldClosedGroupRatchetTableCommand());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldVersion < lokiV16) {
|
||||||
|
db.execSQL(LokiAPIDatabase.getCreateOpenGroupProfilePictureTableCommand());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldVersion < lokiV17) {
|
||||||
|
db.execSQL("ALTER TABLE part ADD COLUMN audio_visual_samples BLOB");
|
||||||
|
db.execSQL("ALTER TABLE part ADD COLUMN audio_duration INTEGER");
|
||||||
|
}
|
||||||
|
|
||||||
db.setTransactionSuccessful();
|
db.setTransactionSuccessful();
|
||||||
} finally {
|
} finally {
|
||||||
db.endTransaction();
|
db.endTransaction();
|
||||||
|
@ -159,6 +159,7 @@ public class SignalCommunicationModule {
|
|||||||
DatabaseFactory.getLokiPreKeyBundleDatabase(context),
|
DatabaseFactory.getLokiPreKeyBundleDatabase(context),
|
||||||
new SessionResetImplementation(context),
|
new SessionResetImplementation(context),
|
||||||
DatabaseFactory.getLokiUserDatabase(context),
|
DatabaseFactory.getLokiUserDatabase(context),
|
||||||
|
DatabaseFactory.getGroupDatabase(context),
|
||||||
((ApplicationContext)context.getApplicationContext()).broadcaster);
|
((ApplicationContext)context.getApplicationContext()).broadcaster);
|
||||||
} else {
|
} else {
|
||||||
this.messageSender.setMessagePipe(IncomingMessageObserver.getPipe(), IncomingMessageObserver.getUnidentifiedPipe());
|
this.messageSender.setMessagePipe(IncomingMessageObserver.getPipe(), IncomingMessageObserver.getUnidentifiedPipe());
|
||||||
|
@ -85,7 +85,7 @@ public class GroupManager {
|
|||||||
groupDatabase.create(groupId, name, new LinkedList<>(memberAddresses), null, null, new LinkedList<>(adminAddresses));
|
groupDatabase.create(groupId, name, new LinkedList<>(memberAddresses), null, null, new LinkedList<>(adminAddresses));
|
||||||
|
|
||||||
if (!mms) {
|
if (!mms) {
|
||||||
groupDatabase.updateAvatar(groupId, avatarBytes);
|
groupDatabase.updateProfilePicture(groupId, avatarBytes);
|
||||||
DatabaseFactory.getRecipientDatabase(context).setProfileSharing(groupRecipient, true);
|
DatabaseFactory.getRecipientDatabase(context).setProfileSharing(groupRecipient, true);
|
||||||
return sendGroupUpdate(context, groupId, memberAddresses, name, avatarBytes, adminAddresses);
|
return sendGroupUpdate(context, groupId, memberAddresses, name, avatarBytes, adminAddresses);
|
||||||
} else {
|
} else {
|
||||||
@ -125,7 +125,7 @@ public class GroupManager {
|
|||||||
memberAddresses.add(Address.fromSerialized(TextSecurePreferences.getLocalNumber(context)));
|
memberAddresses.add(Address.fromSerialized(TextSecurePreferences.getLocalNumber(context)));
|
||||||
groupDatabase.create(groupId, name, new LinkedList<>(memberAddresses), null, null, new LinkedList<>());
|
groupDatabase.create(groupId, name, new LinkedList<>(memberAddresses), null, null, new LinkedList<>());
|
||||||
|
|
||||||
groupDatabase.updateAvatar(groupId, avatarBytes);
|
groupDatabase.updateProfilePicture(groupId, avatarBytes);
|
||||||
|
|
||||||
long threadID = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(groupRecipient, ThreadDatabase.DistributionTypes.CONVERSATION);
|
long threadID = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(groupRecipient, ThreadDatabase.DistributionTypes.CONVERSATION);
|
||||||
return new GroupActionResult(groupRecipient, threadID);
|
return new GroupActionResult(groupRecipient, threadID);
|
||||||
@ -148,7 +148,7 @@ public class GroupManager {
|
|||||||
groupDatabase.updateMembers(groupId, new LinkedList<>(memberAddresses));
|
groupDatabase.updateMembers(groupId, new LinkedList<>(memberAddresses));
|
||||||
groupDatabase.updateAdmins(groupId, new LinkedList<>(adminAddresses));
|
groupDatabase.updateAdmins(groupId, new LinkedList<>(adminAddresses));
|
||||||
groupDatabase.updateTitle(groupId, name);
|
groupDatabase.updateTitle(groupId, name);
|
||||||
groupDatabase.updateAvatar(groupId, avatarBytes);
|
groupDatabase.updateProfilePicture(groupId, avatarBytes);
|
||||||
|
|
||||||
if (!GroupUtil.isMmsGroup(groupId)) {
|
if (!GroupUtil.isMmsGroup(groupId)) {
|
||||||
return sendGroupUpdate(context, groupId, memberAddresses, name, avatarBytes, adminAddresses);
|
return sendGroupUpdate(context, groupId, memberAddresses, name, avatarBytes, adminAddresses);
|
||||||
|
@ -1,13 +1,19 @@
|
|||||||
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.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 +219,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 +317,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,
|
||||||
|
@ -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) {
|
||||||
|
@ -95,7 +95,7 @@ public class AvatarDownloadJob extends BaseJob implements InjectableType {
|
|||||||
InputStream inputStream = receiver.retrieveAttachment(pointer, attachment, MAX_AVATAR_SIZE);
|
InputStream inputStream = receiver.retrieveAttachment(pointer, attachment, MAX_AVATAR_SIZE);
|
||||||
Bitmap avatar = BitmapUtil.createScaledBitmap(context, new AttachmentModel(attachment, key, 0, digest), 500, 500);
|
Bitmap avatar = BitmapUtil.createScaledBitmap(context, new AttachmentModel(attachment, key, 0, digest), 500, 500);
|
||||||
|
|
||||||
database.updateAvatar(groupId, avatar);
|
database.updateProfilePicture(groupId, avatar);
|
||||||
inputStream.close();
|
inputStream.close();
|
||||||
}
|
}
|
||||||
} catch (BitmapDecodingException | NonSuccessfulResponseCodeException | InvalidMessageException e) {
|
} catch (BitmapDecodingException | NonSuccessfulResponseCodeException | InvalidMessageException e) {
|
||||||
|
@ -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());
|
||||||
}};
|
}};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
|
||||||
@ -340,20 +342,18 @@ class HomeActivity : PassphraseRequiredActionBarActivity, ConversationClickListe
|
|||||||
val threadID = thread.threadId
|
val threadID = thread.threadId
|
||||||
val recipient = thread.recipient
|
val recipient = thread.recipient
|
||||||
val threadDB = DatabaseFactory.getThreadDatabase(this)
|
val threadDB = DatabaseFactory.getThreadDatabase(this)
|
||||||
val deleteThread = object : Runnable {
|
val deleteThread = Runnable {
|
||||||
|
AsyncTask.execute {
|
||||||
override fun run() {
|
val publicChat = DatabaseFactory.getLokiThreadDatabase(this@HomeActivity).getPublicChat(threadID)
|
||||||
AsyncTask.execute {
|
if (publicChat != null) {
|
||||||
val publicChat = DatabaseFactory.getLokiThreadDatabase(this@HomeActivity).getPublicChat(threadID)
|
val apiDB = DatabaseFactory.getLokiAPIDatabase(this@HomeActivity)
|
||||||
if (publicChat != null) {
|
apiDB.removeLastMessageServerID(publicChat.channel, publicChat.server)
|
||||||
val apiDB = DatabaseFactory.getLokiAPIDatabase(this@HomeActivity)
|
apiDB.removeLastDeletionServerID(publicChat.channel, publicChat.server)
|
||||||
apiDB.removeLastMessageServerID(publicChat.channel, publicChat.server)
|
apiDB.clearOpenGroupProfilePictureURL(publicChat.channel, publicChat.server)
|
||||||
apiDB.removeLastDeletionServerID(publicChat.channel, publicChat.server)
|
ApplicationContext.getInstance(this@HomeActivity).publicChatAPI!!.leave(publicChat.channel, publicChat.server)
|
||||||
ApplicationContext.getInstance(this@HomeActivity).publicChatAPI!!.leave(publicChat.channel, publicChat.server)
|
|
||||||
}
|
|
||||||
threadDB.deleteConversation(threadID)
|
|
||||||
ApplicationContext.getInstance(this@HomeActivity).messageNotifier.updateNotification(this@HomeActivity)
|
|
||||||
}
|
}
|
||||||
|
threadDB.deleteConversation(threadID)
|
||||||
|
ApplicationContext.getInstance(this@HomeActivity).messageNotifier.updateNotification(this@HomeActivity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val dialogMessage = if (recipient.isGroupRecipient) R.string.activity_home_leave_group_dialog_message else R.string.activity_home_delete_conversation_dialog_message
|
val dialogMessage = if (recipient.isGroupRecipient) R.string.activity_home_leave_group_dialog_message else R.string.activity_home_delete_conversation_dialog_message
|
||||||
|
@ -82,7 +82,7 @@ class PathActivity : PassphraseRequiredActionBarActivity() {
|
|||||||
|
|
||||||
private fun update(isAnimated: Boolean) {
|
private fun update(isAnimated: Boolean) {
|
||||||
pathRowsContainer.removeAllViews()
|
pathRowsContainer.removeAllViews()
|
||||||
if (OnionRequestAPI.paths.count() >= OnionRequestAPI.pathCount) {
|
if (OnionRequestAPI.paths.isNotEmpty()) {
|
||||||
val path = OnionRequestAPI.paths.firstOrNull() ?: return finish()
|
val path = OnionRequestAPI.paths.firstOrNull() ?: return finish()
|
||||||
val dotAnimationRepeatInterval = path.count().toLong() * 1000 + 1000
|
val dotAnimationRepeatInterval = path.count().toLong() * 1000 + 1000
|
||||||
val pathRows = path.mapIndexed { index, snode ->
|
val pathRows = path.mapIndexed { index, snode ->
|
||||||
|
@ -13,8 +13,12 @@ import android.text.SpannableStringBuilder
|
|||||||
import android.text.method.LinkMovementMethod
|
import android.text.method.LinkMovementMethod
|
||||||
import android.text.style.ClickableSpan
|
import android.text.style.ClickableSpan
|
||||||
import android.text.style.StyleSpan
|
import android.text.style.StyleSpan
|
||||||
|
import android.util.Log
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import com.goterl.lazycode.lazysodium.LazySodiumAndroid
|
||||||
|
import com.goterl.lazycode.lazysodium.SodiumAndroid
|
||||||
|
import com.goterl.lazycode.lazysodium.utils.KeyPair
|
||||||
import kotlinx.android.synthetic.main.activity_register.*
|
import kotlinx.android.synthetic.main.activity_register.*
|
||||||
import network.loki.messenger.R
|
import network.loki.messenger.R
|
||||||
import org.thoughtcrime.securesms.BaseActionBarActivity
|
import org.thoughtcrime.securesms.BaseActionBarActivity
|
||||||
@ -28,16 +32,15 @@ import org.thoughtcrime.securesms.util.Base64
|
|||||||
import org.thoughtcrime.securesms.util.Hex
|
import org.thoughtcrime.securesms.util.Hex
|
||||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||||
import org.whispersystems.curve25519.Curve25519
|
import org.whispersystems.curve25519.Curve25519
|
||||||
import org.whispersystems.libsignal.ecc.Curve
|
import org.whispersystems.libsignal.ecc.*
|
||||||
import org.whispersystems.libsignal.ecc.ECKeyPair
|
|
||||||
import org.whispersystems.libsignal.util.KeyHelper
|
import org.whispersystems.libsignal.util.KeyHelper
|
||||||
import org.whispersystems.signalservice.loki.utilities.hexEncodedPublicKey
|
import org.whispersystems.signalservice.loki.utilities.hexEncodedPublicKey
|
||||||
import java.io.File
|
|
||||||
import java.io.FileOutputStream
|
|
||||||
|
|
||||||
class RegisterActivity : BaseActionBarActivity() {
|
class RegisterActivity : BaseActionBarActivity() {
|
||||||
|
private val sodium = LazySodiumAndroid(SodiumAndroid())
|
||||||
private var seed: ByteArray? = null
|
private var seed: ByteArray? = null
|
||||||
private var keyPair: ECKeyPair? = null
|
private var ed25519KeyPair: KeyPair? = null
|
||||||
|
private var x25519KeyPair: ECKeyPair? = null
|
||||||
set(value) { field = value; updatePublicKeyTextView() }
|
set(value) { field = value; updatePublicKeyTextView() }
|
||||||
|
|
||||||
// region Lifecycle
|
// region Lifecycle
|
||||||
@ -72,7 +75,10 @@ class RegisterActivity : BaseActionBarActivity() {
|
|||||||
private fun updateKeyPair() {
|
private fun updateKeyPair() {
|
||||||
val seedCandidate = Curve25519.getInstance(Curve25519.BEST).generateSeed(16)
|
val seedCandidate = Curve25519.getInstance(Curve25519.BEST).generateSeed(16)
|
||||||
try {
|
try {
|
||||||
this.keyPair = Curve.generateKeyPair(seedCandidate + seedCandidate) // Validate the seed
|
val padding = ByteArray(16) { 0 }
|
||||||
|
ed25519KeyPair = sodium.cryptoSignSeedKeypair(seedCandidate + padding)
|
||||||
|
val x25519KeyPair = sodium.convertKeyPairEd25519ToCurve25519(ed25519KeyPair)
|
||||||
|
this.x25519KeyPair = ECKeyPair(DjbECPublicKey(x25519KeyPair.publicKey.asBytes), DjbECPrivateKey(x25519KeyPair.secretKey.asBytes))
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
return updateKeyPair()
|
return updateKeyPair()
|
||||||
}
|
}
|
||||||
@ -80,7 +86,7 @@ class RegisterActivity : BaseActionBarActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun updatePublicKeyTextView() {
|
private fun updatePublicKeyTextView() {
|
||||||
val hexEncodedPublicKey = keyPair!!.hexEncodedPublicKey
|
val hexEncodedPublicKey = x25519KeyPair!!.hexEncodedPublicKey
|
||||||
val characterCount = hexEncodedPublicKey.count()
|
val characterCount = hexEncodedPublicKey.count()
|
||||||
var count = 0
|
var count = 0
|
||||||
val limit = 32
|
val limit = 32
|
||||||
@ -112,9 +118,11 @@ class RegisterActivity : BaseActionBarActivity() {
|
|||||||
// region Interaction
|
// region Interaction
|
||||||
private fun register() {
|
private fun register() {
|
||||||
IdentityKeyUtil.save(this, IdentityKeyUtil.lokiSeedKey, Hex.toStringCondensed(seed))
|
IdentityKeyUtil.save(this, IdentityKeyUtil.lokiSeedKey, Hex.toStringCondensed(seed))
|
||||||
IdentityKeyUtil.save(this, IdentityKeyUtil.IDENTITY_PUBLIC_KEY_PREF, Base64.encodeBytes(keyPair!!.publicKey.serialize()))
|
IdentityKeyUtil.save(this, IdentityKeyUtil.IDENTITY_PUBLIC_KEY_PREF, Base64.encodeBytes(x25519KeyPair!!.publicKey.serialize()))
|
||||||
IdentityKeyUtil.save(this, IdentityKeyUtil.IDENTITY_PRIVATE_KEY_PREF, Base64.encodeBytes(keyPair!!.privateKey.serialize()))
|
IdentityKeyUtil.save(this, IdentityKeyUtil.IDENTITY_PRIVATE_KEY_PREF, Base64.encodeBytes(x25519KeyPair!!.privateKey.serialize()))
|
||||||
val userHexEncodedPublicKey = keyPair!!.hexEncodedPublicKey
|
IdentityKeyUtil.save(this, IdentityKeyUtil.ED25519_PUBLIC_KEY, Base64.encodeBytes(ed25519KeyPair!!.publicKey.asBytes))
|
||||||
|
IdentityKeyUtil.save(this, IdentityKeyUtil.ED25519_SECRET_KEY, Base64.encodeBytes(ed25519KeyPair!!.secretKey.asBytes))
|
||||||
|
val userHexEncodedPublicKey = x25519KeyPair!!.hexEncodedPublicKey
|
||||||
val registrationID = KeyHelper.generateRegistrationId(false)
|
val registrationID = KeyHelper.generateRegistrationId(false)
|
||||||
TextSecurePreferences.setLocalRegistrationId(this, registrationID)
|
TextSecurePreferences.setLocalRegistrationId(this, registrationID)
|
||||||
DatabaseFactory.getIdentityDatabase(this).saveIdentity(Address.fromSerialized(userHexEncodedPublicKey),
|
DatabaseFactory.getIdentityDatabase(this).saveIdentity(Address.fromSerialized(userHexEncodedPublicKey),
|
||||||
@ -129,7 +137,7 @@ class RegisterActivity : BaseActionBarActivity() {
|
|||||||
|
|
||||||
private fun copyPublicKey() {
|
private fun copyPublicKey() {
|
||||||
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
val clip = ClipData.newPlainText("Session ID", keyPair!!.hexEncodedPublicKey)
|
val clip = ClipData.newPlainText("Session ID", x25519KeyPair!!.hexEncodedPublicKey)
|
||||||
clipboard.setPrimaryClip(clip)
|
clipboard.setPrimaryClip(clip)
|
||||||
Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show()
|
Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
|
@ -50,13 +50,16 @@ class BackgroundPollJob private constructor(parameters: Parameters) : BaseJob(pa
|
|||||||
Log.d("Loki", "Performing background poll.")
|
Log.d("Loki", "Performing background poll.")
|
||||||
val userPublicKey = TextSecurePreferences.getLocalNumber(context)
|
val userPublicKey = TextSecurePreferences.getLocalNumber(context)
|
||||||
val promises = mutableListOf<Promise<Unit, Exception>>()
|
val promises = mutableListOf<Promise<Unit, Exception>>()
|
||||||
val promise = SnodeAPI.shared.getMessages(userPublicKey).map { envelopes ->
|
if (!TextSecurePreferences.isUsingFCM(context)) {
|
||||||
envelopes.forEach {
|
Log.d("Loki", "Not using FCM; polling for contacts and closed groups.")
|
||||||
PushContentReceiveJob(context).processEnvelope(SignalServiceEnvelope(it), false)
|
val promise = SnodeAPI.shared.getMessages(userPublicKey).map { envelopes ->
|
||||||
|
envelopes.forEach {
|
||||||
|
PushContentReceiveJob(context).processEnvelope(SignalServiceEnvelope(it), false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
promises.add(promise)
|
||||||
|
promises.addAll(ClosedGroupPoller.shared.pollOnce())
|
||||||
}
|
}
|
||||||
promises.add(promise)
|
|
||||||
promises.addAll(ClosedGroupPoller.shared.pollOnce())
|
|
||||||
val openGroups = DatabaseFactory.getLokiThreadDatabase(context).getAllPublicChats().map { it.value }
|
val openGroups = DatabaseFactory.getLokiThreadDatabase(context).getAllPublicChats().map { it.value }
|
||||||
for (openGroup in openGroups) {
|
for (openGroup in openGroups) {
|
||||||
val poller = PublicChatPoller(context, openGroup)
|
val poller = PublicChatPoller(context, openGroup)
|
||||||
|
@ -0,0 +1,167 @@
|
|||||||
|
package org.thoughtcrime.securesms.loki.api
|
||||||
|
|
||||||
|
import android.media.MediaDataSource
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import org.greenrobot.eventbus.EventBus
|
||||||
|
import org.thoughtcrime.securesms.attachments.Attachment
|
||||||
|
import org.thoughtcrime.securesms.attachments.AttachmentId
|
||||||
|
import org.thoughtcrime.securesms.attachments.DatabaseAttachmentAudioExtras
|
||||||
|
import org.thoughtcrime.securesms.database.DatabaseFactory
|
||||||
|
import org.thoughtcrime.securesms.jobmanager.Data
|
||||||
|
import org.thoughtcrime.securesms.jobmanager.Job
|
||||||
|
import org.thoughtcrime.securesms.jobs.BaseJob
|
||||||
|
import org.thoughtcrime.securesms.loki.utilities.audio.DecodedAudio
|
||||||
|
import org.thoughtcrime.securesms.mms.PartAuthority
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.lang.IllegalStateException
|
||||||
|
import java.util.*
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes the audio content of the related attachment entry
|
||||||
|
* and caches the result with [DatabaseAttachmentAudioExtras] data.
|
||||||
|
*
|
||||||
|
* It only process attachments with "audio" mime types.
|
||||||
|
*
|
||||||
|
* Due to [DecodedAudio] implementation limitations, it only works for API 23+.
|
||||||
|
* For any lower targets fake data will be generated.
|
||||||
|
*
|
||||||
|
* You can subscribe to [AudioExtrasUpdatedEvent] to be notified about the successful result.
|
||||||
|
*/
|
||||||
|
//TODO AC: Rewrite to WorkManager API when
|
||||||
|
// https://github.com/loki-project/session-android/pull/354 is merged.
|
||||||
|
class PrepareAttachmentAudioExtrasJob : BaseJob {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "AttachAudioExtrasJob"
|
||||||
|
|
||||||
|
const val KEY = "PrepareAttachmentAudioExtrasJob"
|
||||||
|
const val DATA_ATTACH_ID = "attachment_id"
|
||||||
|
|
||||||
|
const val VISUAL_RMS_FRAMES = 32 // The amount of values to be computed for the visualization.
|
||||||
|
}
|
||||||
|
|
||||||
|
private val attachmentId: AttachmentId
|
||||||
|
|
||||||
|
constructor(attachmentId: AttachmentId) : this(Parameters.Builder()
|
||||||
|
.setQueue(KEY)
|
||||||
|
.setLifespan(TimeUnit.DAYS.toMillis(1))
|
||||||
|
.build(),
|
||||||
|
attachmentId)
|
||||||
|
|
||||||
|
private constructor(parameters: Parameters, attachmentId: AttachmentId) : super(parameters) {
|
||||||
|
this.attachmentId = attachmentId
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun serialize(): Data {
|
||||||
|
return Data.Builder().putParcelable(DATA_ATTACH_ID, attachmentId).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getFactoryKey(): String { return KEY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onShouldRetry(e: Exception): Boolean {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCanceled() { }
|
||||||
|
|
||||||
|
override fun onRun() {
|
||||||
|
Log.v(TAG, "Processing attachment: $attachmentId")
|
||||||
|
|
||||||
|
val attachDb = DatabaseFactory.getAttachmentDatabase(context)
|
||||||
|
val attachment = attachDb.getAttachment(attachmentId)
|
||||||
|
|
||||||
|
if (attachment == null) {
|
||||||
|
throw IllegalStateException("Cannot find attachment with the ID $attachmentId")
|
||||||
|
}
|
||||||
|
if (!attachment.contentType.startsWith("audio/")) {
|
||||||
|
throw IllegalStateException("Attachment $attachmentId is not of audio type.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the audio extras already exist.
|
||||||
|
if (attachDb.getAttachmentAudioExtras(attachmentId) != null) return
|
||||||
|
|
||||||
|
fun extractAttachmentRandomSeed(attachment: Attachment): Int {
|
||||||
|
return when {
|
||||||
|
attachment.digest != null -> attachment.digest!!.sum()
|
||||||
|
attachment.fileName != null -> attachment.fileName.hashCode()
|
||||||
|
else -> attachment.hashCode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun generateFakeRms(seed: Int, frames: Int = VISUAL_RMS_FRAMES): ByteArray {
|
||||||
|
return ByteArray(frames).apply { Random(seed.toLong()).nextBytes(this) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var rmsValues: ByteArray
|
||||||
|
var totalDurationMs: Long = DatabaseAttachmentAudioExtras.DURATION_UNDEFINED
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
|
||||||
|
// Due to API version incompatibility, we just display some random waveform for older API.
|
||||||
|
rmsValues = generateFakeRms(extractAttachmentRandomSeed(attachment))
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
|
val decodedAudio = PartAuthority.getAttachmentStream(context, attachment.dataUri!!).use {
|
||||||
|
DecodedAudio.create(InputStreamMediaDataSource(it))
|
||||||
|
}
|
||||||
|
rmsValues = decodedAudio.calculateRms(VISUAL_RMS_FRAMES)
|
||||||
|
totalDurationMs = (decodedAudio.totalDuration / 1000.0).toLong()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to decode sample values for the audio attachment \"${attachment.fileName}\".", e)
|
||||||
|
rmsValues = generateFakeRms(extractAttachmentRandomSeed(attachment))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
attachDb.setAttachmentAudioExtras(DatabaseAttachmentAudioExtras(
|
||||||
|
attachmentId,
|
||||||
|
rmsValues,
|
||||||
|
totalDurationMs
|
||||||
|
))
|
||||||
|
|
||||||
|
EventBus.getDefault().post(AudioExtrasUpdatedEvent(attachmentId))
|
||||||
|
}
|
||||||
|
|
||||||
|
class Factory : Job.Factory<PrepareAttachmentAudioExtrasJob> {
|
||||||
|
override fun create(parameters: Parameters, data: Data): PrepareAttachmentAudioExtrasJob {
|
||||||
|
return PrepareAttachmentAudioExtrasJob(parameters, data.getParcelable(DATA_ATTACH_ID, AttachmentId.CREATOR))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gets dispatched once the audio extras have been updated. */
|
||||||
|
data class AudioExtrasUpdatedEvent(val attachmentId: AttachmentId)
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.M)
|
||||||
|
private class InputStreamMediaDataSource: MediaDataSource {
|
||||||
|
|
||||||
|
private val data: ByteArray
|
||||||
|
|
||||||
|
constructor(inputStream: InputStream): super() {
|
||||||
|
this.data = inputStream.readBytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun readAt(position: Long, buffer: ByteArray, offset: Int, size: Int): Int {
|
||||||
|
val length: Int = data.size
|
||||||
|
if (position >= length) {
|
||||||
|
return -1 // -1 indicates EOF
|
||||||
|
}
|
||||||
|
var actualSize = size
|
||||||
|
if (position + size > length) {
|
||||||
|
actualSize -= (position + size - length).toInt()
|
||||||
|
}
|
||||||
|
System.arraycopy(data, position.toInt(), buffer, offset, actualSize)
|
||||||
|
return actualSize
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getSize(): Long {
|
||||||
|
return data.size.toLong()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
// We don't need to close the wrapped stream.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.loki.api
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.database.ContentObserver
|
import android.database.ContentObserver
|
||||||
|
import android.graphics.Bitmap
|
||||||
import android.text.TextUtils
|
import android.text.TextUtils
|
||||||
import nl.komponents.kovenant.Promise
|
import nl.komponents.kovenant.Promise
|
||||||
import nl.komponents.kovenant.functional.bind
|
import nl.komponents.kovenant.functional.bind
|
||||||
@ -10,8 +11,10 @@ import org.thoughtcrime.securesms.ApplicationContext
|
|||||||
import org.thoughtcrime.securesms.database.DatabaseContentProviders
|
import org.thoughtcrime.securesms.database.DatabaseContentProviders
|
||||||
import org.thoughtcrime.securesms.database.DatabaseFactory
|
import org.thoughtcrime.securesms.database.DatabaseFactory
|
||||||
import org.thoughtcrime.securesms.groups.GroupManager
|
import org.thoughtcrime.securesms.groups.GroupManager
|
||||||
|
import org.thoughtcrime.securesms.util.BitmapUtil
|
||||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||||
import org.thoughtcrime.securesms.util.Util
|
import org.thoughtcrime.securesms.util.Util
|
||||||
|
import org.whispersystems.signalservice.loki.api.opengroups.PublicChatInfo
|
||||||
import org.whispersystems.signalservice.loki.api.opengroups.PublicChat
|
import org.whispersystems.signalservice.loki.api.opengroups.PublicChat
|
||||||
|
|
||||||
class PublicChatManager(private val context: Context) {
|
class PublicChatManager(private val context: Context) {
|
||||||
@ -24,8 +27,8 @@ class PublicChatManager(private val context: Context) {
|
|||||||
var areAllCaughtUp = true
|
var areAllCaughtUp = true
|
||||||
refreshChatsAndPollers()
|
refreshChatsAndPollers()
|
||||||
for ((threadID, chat) in chats) {
|
for ((threadID, chat) in chats) {
|
||||||
val poller = pollers[threadID] ?: PublicChatPoller(context, chat)
|
val poller = pollers[threadID]
|
||||||
areAllCaughtUp = areAllCaughtUp && poller.isCaughtUp
|
areAllCaughtUp = if (poller != null) areAllCaughtUp && poller.isCaughtUp else true
|
||||||
}
|
}
|
||||||
return areAllCaughtUp
|
return areAllCaughtUp
|
||||||
}
|
}
|
||||||
@ -56,7 +59,8 @@ class PublicChatManager(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public fun addChat(server: String, channel: Long): Promise<PublicChat, Exception> {
|
public fun addChat(server: String, channel: Long): Promise<PublicChat, Exception> {
|
||||||
val groupChatAPI = ApplicationContext.getInstance(context).publicChatAPI ?: return Promise.ofFail(IllegalStateException("LokiPublicChatAPI is not set!"))
|
val groupChatAPI = ApplicationContext.getInstance(context).publicChatAPI
|
||||||
|
?: return Promise.ofFail(IllegalStateException("LokiPublicChatAPI is not set!"))
|
||||||
return groupChatAPI.getAuthToken(server).bind {
|
return groupChatAPI.getAuthToken(server).bind {
|
||||||
groupChatAPI.getChannelInfo(channel, server)
|
groupChatAPI.getChannelInfo(channel, server)
|
||||||
}.map {
|
}.map {
|
||||||
@ -64,12 +68,18 @@ class PublicChatManager(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public fun addChat(server: String, channel: Long, name: String): PublicChat {
|
public fun addChat(server: String, channel: Long, info: PublicChatInfo): PublicChat {
|
||||||
val chat = PublicChat(channel, server, name, true)
|
val chat = PublicChat(channel, server, info.displayName, true)
|
||||||
var threadID = GroupManager.getOpenGroupThreadID(chat.id, context)
|
var threadID = GroupManager.getOpenGroupThreadID(chat.id, context)
|
||||||
|
var profilePicture: Bitmap? = null
|
||||||
// Create the group if we don't have one
|
// Create the group if we don't have one
|
||||||
if (threadID < 0) {
|
if (threadID < 0) {
|
||||||
val result = GroupManager.createOpenGroup(chat.id, context, null, chat.displayName)
|
if (info.profilePictureURL.isNotEmpty()) {
|
||||||
|
val profilePictureAsByteArray = ApplicationContext.getInstance(context).publicChatAPI
|
||||||
|
?.downloadOpenGroupProfilePicture(server, info.profilePictureURL)
|
||||||
|
profilePicture = BitmapUtil.fromByteArray(profilePictureAsByteArray)
|
||||||
|
}
|
||||||
|
val result = GroupManager.createOpenGroup(chat.id, context, profilePicture, chat.displayName)
|
||||||
threadID = result.threadId
|
threadID = result.threadId
|
||||||
}
|
}
|
||||||
DatabaseFactory.getLokiThreadDatabase(context).setPublicChat(chat, threadID)
|
DatabaseFactory.getLokiThreadDatabase(context).setPublicChat(chat, threadID)
|
||||||
|
@ -46,7 +46,8 @@ class PublicChatPoller(private val context: Context, private val group: PublicCh
|
|||||||
val userPrivateKey = IdentityKeyUtil.getIdentityKeyPair(context).privateKey.serialize()
|
val userPrivateKey = IdentityKeyUtil.getIdentityKeyPair(context).privateKey.serialize()
|
||||||
val lokiAPIDatabase = DatabaseFactory.getLokiAPIDatabase(context)
|
val lokiAPIDatabase = DatabaseFactory.getLokiAPIDatabase(context)
|
||||||
val lokiUserDatabase = DatabaseFactory.getLokiUserDatabase(context)
|
val lokiUserDatabase = DatabaseFactory.getLokiUserDatabase(context)
|
||||||
PublicChatAPI(userHexEncodedPublicKey, userPrivateKey, lokiAPIDatabase, lokiUserDatabase)
|
val openGroupDatabase = DatabaseFactory.getGroupDatabase(context)
|
||||||
|
PublicChatAPI(userHexEncodedPublicKey, userPrivateKey, lokiAPIDatabase, lokiUserDatabase, openGroupDatabase)
|
||||||
}()
|
}()
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
|
@ -6,7 +6,6 @@ import android.util.Log
|
|||||||
import org.thoughtcrime.securesms.database.Database
|
import org.thoughtcrime.securesms.database.Database
|
||||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
|
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
|
||||||
import org.thoughtcrime.securesms.loki.utilities.*
|
import org.thoughtcrime.securesms.loki.utilities.*
|
||||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
|
||||||
import org.whispersystems.signalservice.loki.api.Snode
|
import org.whispersystems.signalservice.loki.api.Snode
|
||||||
import org.whispersystems.signalservice.loki.database.LokiAPIDatabaseProtocol
|
import org.whispersystems.signalservice.loki.database.LokiAPIDatabaseProtocol
|
||||||
import org.whispersystems.signalservice.loki.protocol.shelved.multidevice.DeviceLink
|
import org.whispersystems.signalservice.loki.protocol.shelved.multidevice.DeviceLink
|
||||||
@ -71,6 +70,10 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
|
|||||||
// Open group public keys
|
// Open group public keys
|
||||||
private val openGroupPublicKeyTable = "open_group_public_keys"
|
private val openGroupPublicKeyTable = "open_group_public_keys"
|
||||||
@JvmStatic val createOpenGroupPublicKeyTableCommand = "CREATE TABLE $openGroupPublicKeyTable ($server STRING PRIMARY KEY, $publicKey INTEGER DEFAULT 0);"
|
@JvmStatic val createOpenGroupPublicKeyTableCommand = "CREATE TABLE $openGroupPublicKeyTable ($server STRING PRIMARY KEY, $publicKey INTEGER DEFAULT 0);"
|
||||||
|
// Open group profile picture cache
|
||||||
|
private val openGroupProfilePictureTable = "open_group_avatar_cache"
|
||||||
|
private val openGroupProfilePicture = "open_group_avatar"
|
||||||
|
@JvmStatic val createOpenGroupProfilePictureTableCommand = "CREATE TABLE $openGroupProfilePictureTable ($publicChatID STRING PRIMARY KEY, $openGroupProfilePicture TEXT NULLABLE DEFAULT NULL);"
|
||||||
|
|
||||||
// region Deprecated
|
// region Deprecated
|
||||||
private val deviceLinkCache = "loki_pairing_authorisation_cache"
|
private val deviceLinkCache = "loki_pairing_authorisation_cache"
|
||||||
@ -114,6 +117,30 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
|
|||||||
database.insertOrUpdate(snodePoolTable, row, "${Companion.dummyKey} = ?", wrap("dummy_key"))
|
database.insertOrUpdate(snodePoolTable, row, "${Companion.dummyKey} = ?", wrap("dummy_key"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun setOnionRequestPaths(newValue: List<List<Snode>>) {
|
||||||
|
// FIXME: This approach assumes either 1 or 2 paths of length 3 each. We should do better than this.
|
||||||
|
val database = databaseHelper.writableDatabase
|
||||||
|
fun set(indexPath: String, snode: Snode) {
|
||||||
|
var snodeAsString = "${snode.address}-${snode.port}"
|
||||||
|
val keySet = snode.publicKeySet
|
||||||
|
if (keySet != null) {
|
||||||
|
snodeAsString += "-${keySet.ed25519Key}-${keySet.x25519Key}"
|
||||||
|
}
|
||||||
|
val row = wrap(mapOf( Companion.indexPath to indexPath, Companion.snode to snodeAsString ))
|
||||||
|
database.insertOrUpdate(onionRequestPathTable, row, "${Companion.indexPath} = ?", wrap(indexPath))
|
||||||
|
}
|
||||||
|
Log.d("Loki", "Persisting onion request paths to database.")
|
||||||
|
clearOnionRequestPaths()
|
||||||
|
if (newValue.count() < 1) { return }
|
||||||
|
val path0 = newValue[0]
|
||||||
|
if (path0.count() != 3) { return }
|
||||||
|
set("0-0", path0[0]); set("0-1", path0[1]); set("0-2", path0[2])
|
||||||
|
if (newValue.count() < 2) { return }
|
||||||
|
val path1 = newValue[1]
|
||||||
|
if (path1.count() != 3) { return }
|
||||||
|
set("1-0", path1[0]); set("1-1", path1[1]); set("1-2", path1[2])
|
||||||
|
}
|
||||||
|
|
||||||
override fun getOnionRequestPaths(): List<List<Snode>> {
|
override fun getOnionRequestPaths(): List<List<Snode>> {
|
||||||
val database = databaseHelper.readableDatabase
|
val database = databaseHelper.readableDatabase
|
||||||
fun get(indexPath: String): Snode? {
|
fun get(indexPath: String): Snode? {
|
||||||
@ -131,10 +158,16 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val path0Snode0 = get("0-0") ?: return listOf(); val path0Snode1 = get("0-1") ?: return listOf()
|
val result = mutableListOf<List<Snode>>()
|
||||||
val path0Snode2 = get("0-2") ?: return listOf(); val path1Snode0 = get("1-0") ?: return listOf()
|
val path0Snode0 = get("0-0"); val path0Snode1 = get("0-1"); val path0Snode2 = get("0-2")
|
||||||
val path1Snode1 = get("1-1") ?: return listOf(); val path1Snode2 = get("1-2") ?: return listOf()
|
if (path0Snode0 != null && path0Snode1 != null && path0Snode2 != null) {
|
||||||
return listOf( listOf( path0Snode0, path0Snode1, path0Snode2 ), listOf( path1Snode0, path1Snode1, path1Snode2 ) )
|
result.add(listOf( path0Snode0, path0Snode1, path0Snode2 ))
|
||||||
|
}
|
||||||
|
val path1Snode0 = get("1-0"); val path1Snode1 = get("1-1"); val path1Snode2 = get("1-2")
|
||||||
|
if (path1Snode0 != null && path1Snode1 != null && path1Snode2 != null) {
|
||||||
|
result.add(listOf( path1Snode0, path1Snode1, path1Snode2 ))
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun clearOnionRequestPaths() {
|
override fun clearOnionRequestPaths() {
|
||||||
@ -147,28 +180,6 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
|
|||||||
delete("1-1"); delete("1-2")
|
delete("1-1"); delete("1-2")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setOnionRequestPaths(newValue: List<List<Snode>>) {
|
|
||||||
// TODO: Make this work with arbitrary paths
|
|
||||||
if (newValue.count() != 2) { return }
|
|
||||||
val path0 = newValue[0]
|
|
||||||
val path1 = newValue[1]
|
|
||||||
if (path0.count() != 3 || path1.count() != 3) { return }
|
|
||||||
Log.d("Loki", "Persisting onion request paths to database.")
|
|
||||||
val database = databaseHelper.writableDatabase
|
|
||||||
fun set(indexPath: String, snode: Snode) {
|
|
||||||
var snodeAsString = "${snode.address}-${snode.port}"
|
|
||||||
val keySet = snode.publicKeySet
|
|
||||||
if (keySet != null) {
|
|
||||||
snodeAsString += "-${keySet.ed25519Key}-${keySet.x25519Key}"
|
|
||||||
}
|
|
||||||
val row = wrap(mapOf( Companion.indexPath to indexPath, Companion.snode to snodeAsString ))
|
|
||||||
database.insertOrUpdate(onionRequestPathTable, row, "${Companion.indexPath} = ?", wrap(indexPath))
|
|
||||||
}
|
|
||||||
set("0-0", path0[0]); set("0-1", path0[1])
|
|
||||||
set("0-2", path0[2]); set("1-0", path1[0])
|
|
||||||
set("1-1", path1[1]); set("1-2", path1[2])
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getSwarm(publicKey: String): Set<Snode>? {
|
override fun getSwarm(publicKey: String): Set<Snode>? {
|
||||||
val database = databaseHelper.readableDatabase
|
val database = databaseHelper.readableDatabase
|
||||||
return database.get(swarmTable, "${Companion.swarmPublicKey} = ?", wrap(publicKey)) { cursor ->
|
return database.get(swarmTable, "${Companion.swarmPublicKey} = ?", wrap(publicKey)) { cursor ->
|
||||||
@ -343,6 +354,27 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
|
|||||||
database.insertOrUpdate(openGroupPublicKeyTable, row, "${LokiAPIDatabase.server} = ?", wrap(server))
|
database.insertOrUpdate(openGroupPublicKeyTable, row, "${LokiAPIDatabase.server} = ?", wrap(server))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun getOpenGroupProfilePictureURL(group: Long, server: String): String? {
|
||||||
|
val database = databaseHelper.readableDatabase
|
||||||
|
val index = "$server.$group"
|
||||||
|
return database.get(openGroupProfilePictureTable, "$publicChatID = ?", wrap(index)) { cursor ->
|
||||||
|
cursor.getString(openGroupProfilePicture)
|
||||||
|
}?.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setOpenGroupProfilePictureURL(group: Long, server: String, newValue: String) {
|
||||||
|
val database = databaseHelper.writableDatabase
|
||||||
|
val index = "$server.$group"
|
||||||
|
val row = wrap(mapOf(publicChatID to index, openGroupProfilePicture to newValue))
|
||||||
|
database.insertOrUpdate(openGroupProfilePictureTable, row, "$publicChatID = ?", wrap(index))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearOpenGroupProfilePictureURL(group: Long, server: String): Boolean {
|
||||||
|
val database = databaseHelper.writableDatabase
|
||||||
|
val index = "$server.$group"
|
||||||
|
return database.delete(openGroupProfilePictureTable, "$publicChatID = ?", arrayOf(index)) > 0
|
||||||
|
}
|
||||||
|
|
||||||
// region Deprecated
|
// region Deprecated
|
||||||
override fun getDeviceLinks(publicKey: String): Set<DeviceLink> {
|
override fun getDeviceLinks(publicKey: String): Set<DeviceLink> {
|
||||||
return setOf()
|
return setOf()
|
||||||
|
@ -7,6 +7,7 @@ import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
|
|||||||
import org.thoughtcrime.securesms.loki.utilities.*
|
import org.thoughtcrime.securesms.loki.utilities.*
|
||||||
import org.thoughtcrime.securesms.util.Hex
|
import org.thoughtcrime.securesms.util.Hex
|
||||||
import org.whispersystems.signalservice.loki.protocol.closedgroups.ClosedGroupRatchet
|
import org.whispersystems.signalservice.loki.protocol.closedgroups.ClosedGroupRatchet
|
||||||
|
import org.whispersystems.signalservice.loki.protocol.closedgroups.ClosedGroupRatchetCollectionType
|
||||||
import org.whispersystems.signalservice.loki.protocol.closedgroups.ClosedGroupSenderKey
|
import org.whispersystems.signalservice.loki.protocol.closedgroups.ClosedGroupSenderKey
|
||||||
import org.whispersystems.signalservice.loki.protocol.closedgroups.SharedSenderKeysDatabaseProtocol
|
import org.whispersystems.signalservice.loki.protocol.closedgroups.SharedSenderKeysDatabaseProtocol
|
||||||
import org.whispersystems.signalservice.loki.utilities.PublicKeyValidation
|
import org.whispersystems.signalservice.loki.utilities.PublicKeyValidation
|
||||||
@ -17,13 +18,18 @@ class SharedSenderKeysDatabase(context: Context, helper: SQLCipherOpenHelper) :
|
|||||||
// Shared
|
// Shared
|
||||||
private val closedGroupPublicKey = "closed_group_public_key"
|
private val closedGroupPublicKey = "closed_group_public_key"
|
||||||
// Ratchets
|
// Ratchets
|
||||||
private val closedGroupRatchetTable = "closed_group_ratchet_table"
|
private val oldClosedGroupRatchetTable = "old_closed_group_ratchet_table"
|
||||||
|
private val currentClosedGroupRatchetTable = "closed_group_ratchet_table"
|
||||||
private val senderPublicKey = "sender_public_key"
|
private val senderPublicKey = "sender_public_key"
|
||||||
private val chainKey = "chain_key"
|
private val chainKey = "chain_key"
|
||||||
private val keyIndex = "key_index"
|
private val keyIndex = "key_index"
|
||||||
private val messageKeys = "message_keys"
|
private val messageKeys = "message_keys"
|
||||||
@JvmStatic val createClosedGroupRatchetTableCommand
|
@JvmStatic val createOldClosedGroupRatchetTableCommand
|
||||||
= "CREATE TABLE $closedGroupRatchetTable ($closedGroupPublicKey STRING, $senderPublicKey STRING, $chainKey STRING, " +
|
= "CREATE TABLE $oldClosedGroupRatchetTable ($closedGroupPublicKey STRING, $senderPublicKey STRING, $chainKey STRING, " +
|
||||||
|
"$keyIndex INTEGER DEFAULT 0, $messageKeys TEXT, PRIMARY KEY ($closedGroupPublicKey, $senderPublicKey));"
|
||||||
|
// Private keys
|
||||||
|
@JvmStatic val createCurrentClosedGroupRatchetTableCommand
|
||||||
|
= "CREATE TABLE $currentClosedGroupRatchetTable ($closedGroupPublicKey STRING, $senderPublicKey STRING, $chainKey STRING, " +
|
||||||
"$keyIndex INTEGER DEFAULT 0, $messageKeys TEXT, PRIMARY KEY ($closedGroupPublicKey, $senderPublicKey));"
|
"$keyIndex INTEGER DEFAULT 0, $messageKeys TEXT, PRIMARY KEY ($closedGroupPublicKey, $senderPublicKey));"
|
||||||
// Private keys
|
// Private keys
|
||||||
private val closedGroupPrivateKeyTable = "closed_group_private_key_table"
|
private val closedGroupPrivateKeyTable = "closed_group_private_key_table"
|
||||||
@ -32,11 +38,18 @@ class SharedSenderKeysDatabase(context: Context, helper: SQLCipherOpenHelper) :
|
|||||||
= "CREATE TABLE $closedGroupPrivateKeyTable ($closedGroupPublicKey STRING PRIMARY KEY, $closedGroupPrivateKey STRING);"
|
= "CREATE TABLE $closedGroupPrivateKeyTable ($closedGroupPublicKey STRING PRIMARY KEY, $closedGroupPrivateKey STRING);"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getTable(collection: ClosedGroupRatchetCollectionType): String {
|
||||||
|
return when (collection) {
|
||||||
|
ClosedGroupRatchetCollectionType.Old -> oldClosedGroupRatchetTable
|
||||||
|
ClosedGroupRatchetCollectionType.Current -> currentClosedGroupRatchetTable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// region Ratchets & Sender Keys
|
// region Ratchets & Sender Keys
|
||||||
override fun getClosedGroupRatchet(groupPublicKey: String, senderPublicKey: String): ClosedGroupRatchet? {
|
override fun getClosedGroupRatchet(groupPublicKey: String, senderPublicKey: String, collection: ClosedGroupRatchetCollectionType): ClosedGroupRatchet? {
|
||||||
val database = databaseHelper.readableDatabase
|
val database = databaseHelper.readableDatabase
|
||||||
val query = "${Companion.closedGroupPublicKey} = ? AND ${Companion.senderPublicKey} = ?"
|
val query = "${Companion.closedGroupPublicKey} = ? AND ${Companion.senderPublicKey} = ?"
|
||||||
return database.get(closedGroupRatchetTable, query, arrayOf( groupPublicKey, senderPublicKey )) { cursor ->
|
return database.get(getTable(collection), query, arrayOf( groupPublicKey, senderPublicKey )) { cursor ->
|
||||||
val chainKey = cursor.getString(Companion.chainKey)
|
val chainKey = cursor.getString(Companion.chainKey)
|
||||||
val keyIndex = cursor.getInt(Companion.keyIndex)
|
val keyIndex = cursor.getInt(Companion.keyIndex)
|
||||||
val messageKeys = cursor.getString(Companion.messageKeys).split("-")
|
val messageKeys = cursor.getString(Companion.messageKeys).split("-")
|
||||||
@ -44,7 +57,7 @@ class SharedSenderKeysDatabase(context: Context, helper: SQLCipherOpenHelper) :
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setClosedGroupRatchet(groupPublicKey: String, senderPublicKey: String, ratchet: ClosedGroupRatchet) {
|
override fun setClosedGroupRatchet(groupPublicKey: String, senderPublicKey: String, ratchet: ClosedGroupRatchet, collection: ClosedGroupRatchetCollectionType) {
|
||||||
val database = databaseHelper.writableDatabase
|
val database = databaseHelper.writableDatabase
|
||||||
val values = ContentValues()
|
val values = ContentValues()
|
||||||
values.put(Companion.closedGroupPublicKey, groupPublicKey)
|
values.put(Companion.closedGroupPublicKey, groupPublicKey)
|
||||||
@ -53,23 +66,33 @@ class SharedSenderKeysDatabase(context: Context, helper: SQLCipherOpenHelper) :
|
|||||||
values.put(Companion.keyIndex, ratchet.keyIndex)
|
values.put(Companion.keyIndex, ratchet.keyIndex)
|
||||||
values.put(Companion.messageKeys, ratchet.messageKeys.joinToString("-"))
|
values.put(Companion.messageKeys, ratchet.messageKeys.joinToString("-"))
|
||||||
val query = "${Companion.closedGroupPublicKey} = ? AND ${Companion.senderPublicKey} = ?"
|
val query = "${Companion.closedGroupPublicKey} = ? AND ${Companion.senderPublicKey} = ?"
|
||||||
database.insertOrUpdate(closedGroupRatchetTable, values, query, arrayOf( groupPublicKey, senderPublicKey ))
|
database.insertOrUpdate(getTable(collection), values, query, arrayOf( groupPublicKey, senderPublicKey ))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun removeAllClosedGroupRatchets(groupPublicKey: String) {
|
override fun removeAllClosedGroupRatchets(groupPublicKey: String, collection: ClosedGroupRatchetCollectionType) {
|
||||||
val database = databaseHelper.writableDatabase
|
val database = databaseHelper.writableDatabase
|
||||||
val query = "${Companion.closedGroupPublicKey} = ?"
|
val query = "${Companion.closedGroupPublicKey} = ?"
|
||||||
database.delete(closedGroupRatchetTable, query, arrayOf( groupPublicKey ))
|
database.delete(getTable(collection), query, arrayOf( groupPublicKey ))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getAllClosedGroupSenderKeys(groupPublicKey: String): Set<ClosedGroupSenderKey> {
|
override fun getAllClosedGroupRatchets(groupPublicKey: String, collection: ClosedGroupRatchetCollectionType): Set<Pair<String, ClosedGroupRatchet>> {
|
||||||
val database = databaseHelper.readableDatabase
|
val database = databaseHelper.readableDatabase
|
||||||
val query = "${Companion.closedGroupPublicKey} = ?"
|
val query = "${Companion.closedGroupPublicKey} = ?"
|
||||||
return database.getAll(closedGroupRatchetTable, query, arrayOf( groupPublicKey )) { cursor ->
|
return database.getAll(getTable(collection), query, arrayOf( groupPublicKey )) { cursor ->
|
||||||
val chainKey = cursor.getString(Companion.chainKey)
|
val chainKey = cursor.getString(Companion.chainKey)
|
||||||
val keyIndex = cursor.getInt(Companion.keyIndex)
|
val keyIndex = cursor.getInt(Companion.keyIndex)
|
||||||
|
val messageKeys = cursor.getString(Companion.messageKeys).split("-")
|
||||||
val senderPublicKey = cursor.getString(Companion.senderPublicKey)
|
val senderPublicKey = cursor.getString(Companion.senderPublicKey)
|
||||||
ClosedGroupSenderKey(Hex.fromStringCondensed(chainKey), keyIndex, Hex.fromStringCondensed(senderPublicKey))
|
val ratchet = ClosedGroupRatchet(chainKey, keyIndex, messageKeys)
|
||||||
|
Pair(senderPublicKey, ratchet)
|
||||||
|
}.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAllClosedGroupSenderKeys(groupPublicKey: String, collection: ClosedGroupRatchetCollectionType): Set<ClosedGroupSenderKey> {
|
||||||
|
return getAllClosedGroupRatchets(groupPublicKey, collection).map { pair ->
|
||||||
|
val senderPublicKey = pair.first
|
||||||
|
val ratchet = pair.second
|
||||||
|
ClosedGroupSenderKey(Hex.fromStringCondensed(ratchet.chainKey), ratchet.keyIndex, Hex.fromStringCondensed(senderPublicKey))
|
||||||
}.toSet()
|
}.toSet()
|
||||||
}
|
}
|
||||||
// endregion
|
// endregion
|
||||||
|
@ -27,6 +27,7 @@ import org.whispersystems.signalservice.internal.push.SignalServiceProtos
|
|||||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContext
|
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContext
|
||||||
import org.whispersystems.signalservice.loki.api.SnodeAPI
|
import org.whispersystems.signalservice.loki.api.SnodeAPI
|
||||||
import org.whispersystems.signalservice.loki.protocol.closedgroups.ClosedGroupRatchet
|
import org.whispersystems.signalservice.loki.protocol.closedgroups.ClosedGroupRatchet
|
||||||
|
import org.whispersystems.signalservice.loki.protocol.closedgroups.ClosedGroupRatchetCollectionType
|
||||||
import org.whispersystems.signalservice.loki.protocol.closedgroups.ClosedGroupSenderKey
|
import org.whispersystems.signalservice.loki.protocol.closedgroups.ClosedGroupSenderKey
|
||||||
import org.whispersystems.signalservice.loki.protocol.closedgroups.SharedSenderKeysImplementation
|
import org.whispersystems.signalservice.loki.protocol.closedgroups.SharedSenderKeysImplementation
|
||||||
import org.whispersystems.signalservice.loki.utilities.hexEncodedPrivateKey
|
import org.whispersystems.signalservice.loki.utilities.hexEncodedPrivateKey
|
||||||
@ -151,8 +152,15 @@ object ClosedGroupsProtocol {
|
|||||||
job.setContext(context)
|
job.setContext(context)
|
||||||
job.onRun() // Run the job immediately
|
job.onRun() // Run the job immediately
|
||||||
}
|
}
|
||||||
|
val allOldRatchets = sskDatabase.getAllClosedGroupRatchets(groupPublicKey, ClosedGroupRatchetCollectionType.Current)
|
||||||
|
for (pair in allOldRatchets) {
|
||||||
|
val senderPublicKey = pair.first
|
||||||
|
val ratchet = pair.second
|
||||||
|
val collection = ClosedGroupRatchetCollectionType.Old
|
||||||
|
sskDatabase.setClosedGroupRatchet(groupPublicKey, senderPublicKey, ratchet, collection)
|
||||||
|
}
|
||||||
// Delete all ratchets (it's important that this happens * after * sending out the update)
|
// Delete all ratchets (it's important that this happens * after * sending out the update)
|
||||||
sskDatabase.removeAllClosedGroupRatchets(groupPublicKey)
|
sskDatabase.removeAllClosedGroupRatchets(groupPublicKey, ClosedGroupRatchetCollectionType.Current)
|
||||||
// Remove the group from the user's set of public keys to poll for if the user is leaving. Otherwise generate a new ratchet and
|
// Remove the group from the user's set of public keys to poll for if the user is leaving. Otherwise generate a new ratchet and
|
||||||
// send it out to all members (minus the removed ones) using established channels.
|
// send it out to all members (minus the removed ones) using established channels.
|
||||||
if (isUserLeaving) {
|
if (isUserLeaving) {
|
||||||
@ -197,7 +205,7 @@ object ClosedGroupsProtocol {
|
|||||||
// Establish sessions if needed
|
// Establish sessions if needed
|
||||||
establishSessionsWithMembersIfNeeded(context, newMembers)
|
establishSessionsWithMembersIfNeeded(context, newMembers)
|
||||||
// Send closed group update messages to the new members using established channels
|
// Send closed group update messages to the new members using established channels
|
||||||
var allSenderKeys = sskDatabase.getAllClosedGroupSenderKeys(groupPublicKey);
|
var allSenderKeys = sskDatabase.getAllClosedGroupSenderKeys(groupPublicKey, ClosedGroupRatchetCollectionType.Current)
|
||||||
allSenderKeys = allSenderKeys.union(newSenderKeys)
|
allSenderKeys = allSenderKeys.union(newSenderKeys)
|
||||||
for (member in newMembers) {
|
for (member in newMembers) {
|
||||||
@Suppress("NAME_SHADOWING")
|
@Suppress("NAME_SHADOWING")
|
||||||
@ -208,7 +216,7 @@ object ClosedGroupsProtocol {
|
|||||||
ApplicationContext.getInstance(context).jobManager.add(job)
|
ApplicationContext.getInstance(context).jobManager.add(job)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val allSenderKeys = sskDatabase.getAllClosedGroupSenderKeys(groupPublicKey);
|
val allSenderKeys = sskDatabase.getAllClosedGroupSenderKeys(groupPublicKey, ClosedGroupRatchetCollectionType.Current)
|
||||||
val closedGroupUpdateKind = ClosedGroupUpdateMessageSendJob.Kind.Info(Hex.fromStringCondensed(groupPublicKey), name,
|
val closedGroupUpdateKind = ClosedGroupUpdateMessageSendJob.Kind.Info(Hex.fromStringCondensed(groupPublicKey), name,
|
||||||
allSenderKeys, membersAsData, adminsAsData)
|
allSenderKeys, membersAsData, adminsAsData)
|
||||||
val job = ClosedGroupUpdateMessageSendJob(groupPublicKey, closedGroupUpdateKind)
|
val job = ClosedGroupUpdateMessageSendJob(groupPublicKey, closedGroupUpdateKind)
|
||||||
@ -287,7 +295,7 @@ object ClosedGroupsProtocol {
|
|||||||
senderKeys.forEach { senderKey ->
|
senderKeys.forEach { senderKey ->
|
||||||
if (!members.contains(senderKey.publicKey.toHexString())) { return@forEach }
|
if (!members.contains(senderKey.publicKey.toHexString())) { return@forEach }
|
||||||
val ratchet = ClosedGroupRatchet(senderKey.chainKey.toHexString(), senderKey.keyIndex, listOf())
|
val ratchet = ClosedGroupRatchet(senderKey.chainKey.toHexString(), senderKey.keyIndex, listOf())
|
||||||
sskDatabase.setClosedGroupRatchet(groupPublicKey, senderKey.publicKey.toHexString(), ratchet)
|
sskDatabase.setClosedGroupRatchet(groupPublicKey, senderKey.publicKey.toHexString(), ratchet, ClosedGroupRatchetCollectionType.Current)
|
||||||
}
|
}
|
||||||
// Sort out any discrepancies between the provided sender keys and what's required
|
// Sort out any discrepancies between the provided sender keys and what's required
|
||||||
val missingSenderKeys = members.toSet().subtract(senderKeys.map { Hex.toStringCondensed(it.publicKey) })
|
val missingSenderKeys = members.toSet().subtract(senderKeys.map { Hex.toStringCondensed(it.publicKey) })
|
||||||
@ -357,7 +365,7 @@ object ClosedGroupsProtocol {
|
|||||||
// Store the ratchets for any new members (it's important that this happens before the code below)
|
// Store the ratchets for any new members (it's important that this happens before the code below)
|
||||||
senderKeys.forEach { senderKey ->
|
senderKeys.forEach { senderKey ->
|
||||||
val ratchet = ClosedGroupRatchet(senderKey.chainKey.toHexString(), senderKey.keyIndex, listOf())
|
val ratchet = ClosedGroupRatchet(senderKey.chainKey.toHexString(), senderKey.keyIndex, listOf())
|
||||||
sskDatabase.setClosedGroupRatchet(groupPublicKey, senderKey.publicKey.toHexString(), ratchet)
|
sskDatabase.setClosedGroupRatchet(groupPublicKey, senderKey.publicKey.toHexString(), ratchet, ClosedGroupRatchetCollectionType.Current)
|
||||||
}
|
}
|
||||||
// Delete all ratchets and either:
|
// Delete all ratchets and either:
|
||||||
// • Send out the user's new ratchet using established channels if other members of the group left or were removed
|
// • Send out the user's new ratchet using established channels if other members of the group left or were removed
|
||||||
@ -366,7 +374,14 @@ object ClosedGroupsProtocol {
|
|||||||
val wasAnyUserRemoved = members.toSet().intersect(oldMembers) != oldMembers.toSet()
|
val wasAnyUserRemoved = members.toSet().intersect(oldMembers) != oldMembers.toSet()
|
||||||
val wasSenderRemoved = !members.contains(senderPublicKey)
|
val wasSenderRemoved = !members.contains(senderPublicKey)
|
||||||
if (wasAnyUserRemoved) {
|
if (wasAnyUserRemoved) {
|
||||||
sskDatabase.removeAllClosedGroupRatchets(groupPublicKey)
|
val allOldRatchets = sskDatabase.getAllClosedGroupRatchets(groupPublicKey, ClosedGroupRatchetCollectionType.Current)
|
||||||
|
for (pair in allOldRatchets) {
|
||||||
|
@Suppress("NAME_SHADOWING") val senderPublicKey = pair.first
|
||||||
|
val ratchet = pair.second
|
||||||
|
val collection = ClosedGroupRatchetCollectionType.Old
|
||||||
|
sskDatabase.setClosedGroupRatchet(groupPublicKey, senderPublicKey, ratchet, collection)
|
||||||
|
}
|
||||||
|
sskDatabase.removeAllClosedGroupRatchets(groupPublicKey, ClosedGroupRatchetCollectionType.Current)
|
||||||
if (wasCurrentUserRemoved) {
|
if (wasCurrentUserRemoved) {
|
||||||
sskDatabase.removeClosedGroupPrivateKey(groupPublicKey)
|
sskDatabase.removeClosedGroupPrivateKey(groupPublicKey)
|
||||||
groupDB.setActive(groupID, false)
|
groupDB.setActive(groupID, false)
|
||||||
@ -416,7 +431,7 @@ object ClosedGroupsProtocol {
|
|||||||
// Respond to the request
|
// Respond to the request
|
||||||
Log.d("Loki", "Responding to sender key request from: $senderPublicKey.")
|
Log.d("Loki", "Responding to sender key request from: $senderPublicKey.")
|
||||||
ApplicationContext.getInstance(context).sendSessionRequestIfNeeded(senderPublicKey)
|
ApplicationContext.getInstance(context).sendSessionRequestIfNeeded(senderPublicKey)
|
||||||
val userRatchet = DatabaseFactory.getSSKDatabase(context).getClosedGroupRatchet(groupPublicKey, userPublicKey)
|
val userRatchet = DatabaseFactory.getSSKDatabase(context).getClosedGroupRatchet(groupPublicKey, userPublicKey, ClosedGroupRatchetCollectionType.Current)
|
||||||
?: SharedSenderKeysImplementation.shared.generateRatchet(groupPublicKey, userPublicKey)
|
?: SharedSenderKeysImplementation.shared.generateRatchet(groupPublicKey, userPublicKey)
|
||||||
val userSenderKey = ClosedGroupSenderKey(Hex.fromStringCondensed(userRatchet.chainKey), userRatchet.keyIndex, Hex.fromStringCondensed(userPublicKey))
|
val userSenderKey = ClosedGroupSenderKey(Hex.fromStringCondensed(userRatchet.chainKey), userRatchet.keyIndex, Hex.fromStringCondensed(userPublicKey))
|
||||||
val closedGroupUpdateKind = ClosedGroupUpdateMessageSendJob.Kind.SenderKey(Hex.fromStringCondensed(groupPublicKey), userSenderKey)
|
val closedGroupUpdateKind = ClosedGroupUpdateMessageSendJob.Kind.SenderKey(Hex.fromStringCondensed(groupPublicKey), userSenderKey)
|
||||||
@ -441,7 +456,7 @@ object ClosedGroupsProtocol {
|
|||||||
// Store the sender key
|
// Store the sender key
|
||||||
Log.d("Loki", "Received a sender key from: $senderPublicKey.")
|
Log.d("Loki", "Received a sender key from: $senderPublicKey.")
|
||||||
val ratchet = ClosedGroupRatchet(senderKey.chainKey.toHexString(), senderKey.keyIndex, listOf())
|
val ratchet = ClosedGroupRatchet(senderKey.chainKey.toHexString(), senderKey.keyIndex, listOf())
|
||||||
sskDatabase.setClosedGroupRatchet(groupPublicKey, senderPublicKey, ratchet)
|
sskDatabase.setClosedGroupRatchet(groupPublicKey, senderPublicKey, ratchet, ClosedGroupRatchetCollectionType.Current)
|
||||||
}
|
}
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
|
@ -0,0 +1,368 @@
|
|||||||
|
package org.thoughtcrime.securesms.loki.utilities.audio
|
||||||
|
|
||||||
|
import android.media.AudioFormat
|
||||||
|
import android.media.MediaCodec
|
||||||
|
import android.media.MediaDataSource
|
||||||
|
import android.media.MediaExtractor
|
||||||
|
import android.media.MediaFormat
|
||||||
|
import android.os.Build
|
||||||
|
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
|
||||||
|
import java.io.FileDescriptor
|
||||||
|
import java.io.IOException
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.ByteOrder
|
||||||
|
import java.nio.ShortBuffer
|
||||||
|
import kotlin.jvm.Throws
|
||||||
|
import kotlin.math.ceil
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes the audio data and provides access to its sample data.
|
||||||
|
* We need this to extract RMS values for waveform visualization.
|
||||||
|
*
|
||||||
|
* Use static [DecodedAudio.create] methods to instantiate a [DecodedAudio].
|
||||||
|
*
|
||||||
|
* Partially based on the old [Google's Ringdroid project]
|
||||||
|
* (https://github.com/google/ringdroid/blob/master/app/src/main/java/com/ringdroid/soundfile/SoundFile.java).
|
||||||
|
*
|
||||||
|
* *NOTE:* This class instance creation might be pretty slow (depends on the source audio file size).
|
||||||
|
* It's recommended to instantiate it in the background.
|
||||||
|
*/
|
||||||
|
@Suppress("MemberVisibilityCanBePrivate")
|
||||||
|
class DecodedAudio {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@JvmStatic
|
||||||
|
@Throws(IOException::class)
|
||||||
|
fun create(fd: FileDescriptor, startOffset: Long, size: Long): DecodedAudio {
|
||||||
|
val mediaExtractor = MediaExtractor().apply { setDataSource(fd, startOffset, size) }
|
||||||
|
return DecodedAudio(mediaExtractor, size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
@RequiresApi(api = Build.VERSION_CODES.M)
|
||||||
|
@Throws(IOException::class)
|
||||||
|
fun create(dataSource: MediaDataSource): DecodedAudio {
|
||||||
|
val mediaExtractor = MediaExtractor().apply { setDataSource(dataSource) }
|
||||||
|
return DecodedAudio(mediaExtractor, dataSource.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val dataSize: Long
|
||||||
|
|
||||||
|
/** Average bit rate in kbps. */
|
||||||
|
val avgBitRate: Int
|
||||||
|
|
||||||
|
val sampleRate: Int
|
||||||
|
|
||||||
|
/** In microseconds. */
|
||||||
|
val totalDuration: Long
|
||||||
|
|
||||||
|
val channels: Int
|
||||||
|
|
||||||
|
/** Total number of samples per channel in audio file. */
|
||||||
|
val numSamples: Int
|
||||||
|
|
||||||
|
val samples: ShortBuffer
|
||||||
|
get() {
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
|
||||||
|
Build.VERSION.SDK_INT <= Build.VERSION_CODES.N_MR1
|
||||||
|
) {
|
||||||
|
// Hack for Nougat where asReadOnlyBuffer fails to respect byte ordering.
|
||||||
|
// See https://code.google.com/p/android/issues/detail?id=223824
|
||||||
|
decodedSamples
|
||||||
|
} else {
|
||||||
|
decodedSamples.asReadOnlyBuffer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared buffer with mDecodedBytes.
|
||||||
|
* Has the following format:
|
||||||
|
* {s1c1, s1c2, ..., s1cM, s2c1, ..., s2cM, ..., sNc1, ..., sNcM}
|
||||||
|
* where sicj is the ith sample of the jth channel (a sample is a signed short)
|
||||||
|
* M is the number of channels (e.g. 2 for stereo) and N is the number of samples per channel.
|
||||||
|
*/
|
||||||
|
private val decodedSamples: ShortBuffer
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
private constructor(extractor: MediaExtractor, size: Long) {
|
||||||
|
dataSize = size
|
||||||
|
|
||||||
|
var mediaFormat: MediaFormat? = null
|
||||||
|
// Find and select the first audio track present in the file.
|
||||||
|
for (trackIndex in 0 until extractor.trackCount) {
|
||||||
|
val format = extractor.getTrackFormat(trackIndex)
|
||||||
|
if (format.getString(MediaFormat.KEY_MIME)!!.startsWith("audio/")) {
|
||||||
|
extractor.selectTrack(trackIndex)
|
||||||
|
mediaFormat = format
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mediaFormat == null) {
|
||||||
|
throw IOException("No audio track found in the data source.")
|
||||||
|
}
|
||||||
|
|
||||||
|
channels = mediaFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
|
||||||
|
sampleRate = mediaFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE)
|
||||||
|
// On some old APIs (23) this field might be missing.
|
||||||
|
totalDuration = if (mediaFormat.containsKey(MediaFormat.KEY_DURATION)) {
|
||||||
|
mediaFormat.getLong(MediaFormat.KEY_DURATION)
|
||||||
|
} else {
|
||||||
|
-1L
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expected total number of samples per channel.
|
||||||
|
val expectedNumSamples = if (totalDuration >= 0) {
|
||||||
|
((totalDuration / 1000000f) * sampleRate + 0.5f).toInt()
|
||||||
|
} else {
|
||||||
|
Int.MAX_VALUE
|
||||||
|
}
|
||||||
|
|
||||||
|
val codec = MediaCodec.createDecoderByType(mediaFormat.getString(MediaFormat.KEY_MIME)!!)
|
||||||
|
codec.configure(mediaFormat, null, null, 0)
|
||||||
|
codec.start()
|
||||||
|
|
||||||
|
// Check if the track is in PCM 16 bit encoding.
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
|
try {
|
||||||
|
val pcmEncoding = codec.outputFormat.getInteger(MediaFormat.KEY_PCM_ENCODING)
|
||||||
|
if (pcmEncoding != AudioFormat.ENCODING_PCM_16BIT) {
|
||||||
|
throw IOException("Unsupported PCM encoding code: $pcmEncoding")
|
||||||
|
}
|
||||||
|
} catch (e: NullPointerException) {
|
||||||
|
// If KEY_PCM_ENCODING is not specified, means it's ENCODING_PCM_16BIT.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var decodedSamplesSize: Int = 0 // size of the output buffer containing decoded samples.
|
||||||
|
var decodedSamples: ByteArray? = null
|
||||||
|
var sampleSize: Int
|
||||||
|
val info = MediaCodec.BufferInfo()
|
||||||
|
var presentationTime: Long
|
||||||
|
var totalSizeRead: Int = 0
|
||||||
|
var doneReading = false
|
||||||
|
|
||||||
|
// Set the size of the decoded samples buffer to 1MB (~6sec of a stereo stream at 44.1kHz).
|
||||||
|
// For longer streams, the buffer size will be increased later on, calculating a rough
|
||||||
|
// estimate of the total size needed to store all the samples in order to resize the buffer
|
||||||
|
// only once.
|
||||||
|
var decodedBytes: ByteBuffer = ByteBuffer.allocate(1 shl 20)
|
||||||
|
var firstSampleData = true
|
||||||
|
while (true) {
|
||||||
|
// read data from file and feed it to the decoder input buffers.
|
||||||
|
val inputBufferIndex: Int = codec.dequeueInputBuffer(100)
|
||||||
|
if (!doneReading && inputBufferIndex >= 0) {
|
||||||
|
sampleSize = extractor.readSampleData(codec.getInputBuffer(inputBufferIndex)!!, 0)
|
||||||
|
if (firstSampleData
|
||||||
|
&& mediaFormat.getString(MediaFormat.KEY_MIME)!! == "audio/mp4a-latm"
|
||||||
|
&& sampleSize == 2
|
||||||
|
) {
|
||||||
|
// For some reasons on some devices (e.g. the Samsung S3) you should not
|
||||||
|
// provide the first two bytes of an AAC stream, otherwise the MediaCodec will
|
||||||
|
// crash. These two bytes do not contain music data but basic info on the
|
||||||
|
// stream (e.g. channel configuration and sampling frequency), and skipping them
|
||||||
|
// seems OK with other devices (MediaCodec has already been configured and
|
||||||
|
// already knows these parameters).
|
||||||
|
extractor.advance()
|
||||||
|
totalSizeRead += sampleSize
|
||||||
|
} else if (sampleSize < 0) {
|
||||||
|
// All samples have been read.
|
||||||
|
codec.queueInputBuffer(
|
||||||
|
inputBufferIndex, 0, 0, -1, MediaCodec.BUFFER_FLAG_END_OF_STREAM
|
||||||
|
)
|
||||||
|
doneReading = true
|
||||||
|
} else {
|
||||||
|
presentationTime = extractor.sampleTime
|
||||||
|
codec.queueInputBuffer(inputBufferIndex, 0, sampleSize, presentationTime, 0)
|
||||||
|
extractor.advance()
|
||||||
|
totalSizeRead += sampleSize
|
||||||
|
}
|
||||||
|
firstSampleData = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get decoded stream from the decoder output buffers.
|
||||||
|
val outputBufferIndex: Int = codec.dequeueOutputBuffer(info, 100)
|
||||||
|
if (outputBufferIndex >= 0 && info.size > 0) {
|
||||||
|
if (decodedSamplesSize < info.size) {
|
||||||
|
decodedSamplesSize = info.size
|
||||||
|
decodedSamples = ByteArray(decodedSamplesSize)
|
||||||
|
}
|
||||||
|
val outputBuffer: ByteBuffer = codec.getOutputBuffer(outputBufferIndex)!!
|
||||||
|
outputBuffer.get(decodedSamples!!, 0, info.size)
|
||||||
|
outputBuffer.clear()
|
||||||
|
// Check if buffer is big enough. Resize it if it's too small.
|
||||||
|
if (decodedBytes.remaining() < info.size) {
|
||||||
|
// Getting a rough estimate of the total size, allocate 20% more, and
|
||||||
|
// make sure to allocate at least 5MB more than the initial size.
|
||||||
|
val position = decodedBytes.position()
|
||||||
|
var newSize = ((position * (1.0 * dataSize / totalSizeRead)) * 1.2).toInt()
|
||||||
|
if (newSize - position < info.size + 5 * (1 shl 20)) {
|
||||||
|
newSize = position + info.size + 5 * (1 shl 20)
|
||||||
|
}
|
||||||
|
var newDecodedBytes: ByteBuffer? = null
|
||||||
|
// Try to allocate memory. If we are OOM, try to run the garbage collector.
|
||||||
|
var retry = 10
|
||||||
|
while (retry > 0) {
|
||||||
|
try {
|
||||||
|
newDecodedBytes = ByteBuffer.allocate(newSize)
|
||||||
|
break
|
||||||
|
} catch (e: OutOfMemoryError) {
|
||||||
|
// setting android:largeHeap="true" in <application> seem to help not
|
||||||
|
// reaching this section.
|
||||||
|
retry--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (retry == 0) {
|
||||||
|
// Failed to allocate memory... Stop reading more data and finalize the
|
||||||
|
// instance with the data decoded so far.
|
||||||
|
break
|
||||||
|
}
|
||||||
|
decodedBytes.rewind()
|
||||||
|
newDecodedBytes!!.put(decodedBytes)
|
||||||
|
decodedBytes = newDecodedBytes
|
||||||
|
decodedBytes.position(position)
|
||||||
|
}
|
||||||
|
decodedBytes.put(decodedSamples, 0, info.size)
|
||||||
|
codec.releaseOutputBuffer(outputBufferIndex, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0
|
||||||
|
|| (decodedBytes.position() / (2 * channels)) >= expectedNumSamples
|
||||||
|
) {
|
||||||
|
// We got all the decoded data from the decoder. Stop here.
|
||||||
|
// Theoretically dequeueOutputBuffer(info, ...) should have set info.flags to
|
||||||
|
// MediaCodec.BUFFER_FLAG_END_OF_STREAM. However some phones (e.g. Samsung S3)
|
||||||
|
// won't do that for some files (e.g. with mono AAC files), in which case subsequent
|
||||||
|
// calls to dequeueOutputBuffer may result in the application crashing, without
|
||||||
|
// even an exception being thrown... Hence the second check.
|
||||||
|
// (for mono AAC files, the S3 will actually double each sample, as if the stream
|
||||||
|
// was stereo. The resulting stream is half what it's supposed to be and with a much
|
||||||
|
// lower pitch.)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
numSamples = decodedBytes.position() / (channels * 2) // One sample = 2 bytes.
|
||||||
|
decodedBytes.rewind()
|
||||||
|
decodedBytes.order(ByteOrder.LITTLE_ENDIAN)
|
||||||
|
this.decodedSamples = decodedBytes.asShortBuffer()
|
||||||
|
avgBitRate = ((dataSize * 8) * (sampleRate.toFloat() / numSamples) / 1000).toInt()
|
||||||
|
|
||||||
|
extractor.release()
|
||||||
|
codec.stop()
|
||||||
|
codec.release()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun calculateRms(maxFrames: Int): ByteArray {
|
||||||
|
return calculateRms(this.samples, this.numSamples, this.channels, maxFrames)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes audio RMS values for the first channel only.
|
||||||
|
*
|
||||||
|
* A typical RMS calculation algorithm is:
|
||||||
|
* 1. Square each sample
|
||||||
|
* 2. Sum the squared samples
|
||||||
|
* 3. Divide the sum of the squared samples by the number of samples
|
||||||
|
* 4. Take the square root of step 3., the mean of the squared samples
|
||||||
|
*
|
||||||
|
* @param maxFrames Defines amount of output RMS frames.
|
||||||
|
* If number of samples per channel is less than "maxFrames",
|
||||||
|
* the result array will match the source sample size instead.
|
||||||
|
*
|
||||||
|
* @return normalized RMS values as a signed byte array.
|
||||||
|
*/
|
||||||
|
private fun calculateRms(samples: ShortBuffer, numSamples: Int, channels: Int, maxFrames: Int): ByteArray {
|
||||||
|
val numFrames: Int
|
||||||
|
val frameStep: Float
|
||||||
|
|
||||||
|
val samplesPerChannel = numSamples / channels
|
||||||
|
if (samplesPerChannel <= maxFrames) {
|
||||||
|
frameStep = 1f
|
||||||
|
numFrames = samplesPerChannel
|
||||||
|
} else {
|
||||||
|
frameStep = numSamples / maxFrames.toFloat()
|
||||||
|
numFrames = maxFrames
|
||||||
|
}
|
||||||
|
|
||||||
|
val rmsValues = FloatArray(numFrames)
|
||||||
|
|
||||||
|
var squaredFrameSum = 0.0
|
||||||
|
var currentFrameIdx = 0
|
||||||
|
|
||||||
|
fun calculateFrameRms(nextFrameIdx: Int) {
|
||||||
|
rmsValues[currentFrameIdx] = sqrt(squaredFrameSum.toFloat())
|
||||||
|
|
||||||
|
// Advance to the next frame.
|
||||||
|
squaredFrameSum = 0.0
|
||||||
|
currentFrameIdx = nextFrameIdx
|
||||||
|
}
|
||||||
|
|
||||||
|
(0 until numSamples * channels step channels).forEach { sampleIdx ->
|
||||||
|
val channelSampleIdx = sampleIdx / channels
|
||||||
|
val frameIdx = (channelSampleIdx / frameStep).toInt()
|
||||||
|
|
||||||
|
if (currentFrameIdx != frameIdx) {
|
||||||
|
// Calculate RMS value for the previous frame.
|
||||||
|
calculateFrameRms(frameIdx)
|
||||||
|
}
|
||||||
|
|
||||||
|
val samplesInCurrentFrame = ceil((currentFrameIdx + 1) * frameStep) - ceil(currentFrameIdx * frameStep)
|
||||||
|
squaredFrameSum += (samples[sampleIdx] * samples[sampleIdx]) / samplesInCurrentFrame
|
||||||
|
}
|
||||||
|
// Calculate RMS value for the last frame.
|
||||||
|
calculateFrameRms(-1)
|
||||||
|
|
||||||
|
// smoothArray(rmsValues, 1.0f)
|
||||||
|
normalizeArray(rmsValues)
|
||||||
|
|
||||||
|
// Convert normalized result to a signed byte array.
|
||||||
|
return rmsValues.map { value -> normalizedFloatToByte(value) }.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes the array's values to [0..1] range.
|
||||||
|
*/
|
||||||
|
private fun normalizeArray(values: FloatArray) {
|
||||||
|
var maxValue = -Float.MAX_VALUE
|
||||||
|
var minValue = +Float.MAX_VALUE
|
||||||
|
values.forEach { value ->
|
||||||
|
if (value > maxValue) maxValue = value
|
||||||
|
if (value < minValue) minValue = value
|
||||||
|
}
|
||||||
|
val span = maxValue - minValue
|
||||||
|
|
||||||
|
if (span == 0f) {
|
||||||
|
values.indices.forEach { i -> values[i] = 0f }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
values.indices.forEach { i -> values[i] = (values[i] - minValue) / span }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun smoothArray(values: FloatArray, neighborWeight: Float = 1f): FloatArray {
|
||||||
|
if (values.size < 3) return values
|
||||||
|
|
||||||
|
val result = FloatArray(values.size)
|
||||||
|
result[0] = values[0]
|
||||||
|
result[values.size - 1] == values[values.size - 1]
|
||||||
|
for (i in 1 until values.size - 1) {
|
||||||
|
result[i] = (values[i] + values[i - 1] * neighborWeight +
|
||||||
|
values[i + 1] * neighborWeight) / (1f + neighborWeight * 2f)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Turns a signed byte into a [0..1] float. */
|
||||||
|
inline fun byteToNormalizedFloat(value: Byte): Float {
|
||||||
|
return (value + 128f) / 255f
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Turns a [0..1] float into a signed byte. */
|
||||||
|
inline fun normalizedFloatToByte(value: Float): Byte {
|
||||||
|
return (255f * value - 128f).roundToInt().toByte()
|
||||||
|
}
|
@ -18,8 +18,8 @@ class FakeChatView : ScrollView {
|
|||||||
|
|
||||||
// region Settings
|
// region Settings
|
||||||
private val spacing = context.resources.getDimension(R.dimen.medium_spacing)
|
private val spacing = context.resources.getDimension(R.dimen.medium_spacing)
|
||||||
private val startDelay: Long = 2000
|
private val startDelay: Long = 1000
|
||||||
private val delayBetweenMessages: Long = 3000
|
private val delayBetweenMessages: Long = 1500
|
||||||
private val animationDuration: Long = 400
|
private val animationDuration: Long = 400
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
|
336
src/org/thoughtcrime/securesms/loki/views/MessageAudioView.kt
Normal file
336
src/org/thoughtcrime/securesms/loki/views/MessageAudioView.kt
Normal file
@ -0,0 +1,336 @@
|
|||||||
|
package org.thoughtcrime.securesms.loki.views
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.res.ColorStateList
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.PorterDuff
|
||||||
|
import android.graphics.drawable.AnimatedVectorDrawable
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.view.View
|
||||||
|
import android.view.View.OnTouchListener
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.ProgressBar
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.annotation.ColorInt
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.graphics.ColorUtils
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import network.loki.messenger.R
|
||||||
|
import org.greenrobot.eventbus.EventBus
|
||||||
|
import org.greenrobot.eventbus.Subscribe
|
||||||
|
import org.greenrobot.eventbus.ThreadMode
|
||||||
|
import org.thoughtcrime.securesms.ApplicationContext
|
||||||
|
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
|
||||||
|
import org.thoughtcrime.securesms.audio.AudioSlidePlayer
|
||||||
|
import org.thoughtcrime.securesms.components.AnimatingToggle
|
||||||
|
import org.thoughtcrime.securesms.database.AttachmentDatabase
|
||||||
|
import org.thoughtcrime.securesms.database.DatabaseFactory
|
||||||
|
import org.thoughtcrime.securesms.events.PartProgressEvent
|
||||||
|
import org.thoughtcrime.securesms.logging.Log
|
||||||
|
import org.thoughtcrime.securesms.loki.api.PrepareAttachmentAudioExtrasJob
|
||||||
|
import org.thoughtcrime.securesms.loki.utilities.getColorWithID
|
||||||
|
import org.thoughtcrime.securesms.mms.AudioSlide
|
||||||
|
import org.thoughtcrime.securesms.mms.SlideClickListener
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.*
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class MessageAudioView: FrameLayout, AudioSlidePlayer.Listener {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "AudioViewKt"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val controlToggle: AnimatingToggle
|
||||||
|
private val container: ViewGroup
|
||||||
|
private val playButton: ImageView
|
||||||
|
private val pauseButton: ImageView
|
||||||
|
private val downloadButton: ImageView
|
||||||
|
private val downloadProgress: ProgressBar
|
||||||
|
private val seekBar: WaveformSeekBar
|
||||||
|
private val totalDuration: TextView
|
||||||
|
|
||||||
|
private var downloadListener: SlideClickListener? = null
|
||||||
|
private var audioSlidePlayer: AudioSlidePlayer? = null
|
||||||
|
|
||||||
|
/** Background coroutine scope that is available when the view is attached to a window. */
|
||||||
|
private var asyncCoroutineScope: CoroutineScope? = null
|
||||||
|
|
||||||
|
private val loadingAnimation: SeekBarLoadingAnimation
|
||||||
|
|
||||||
|
constructor(context: Context): this(context, null)
|
||||||
|
|
||||||
|
constructor(context: Context, attrs: AttributeSet?): this(context, attrs, 0)
|
||||||
|
|
||||||
|
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int): super(context, attrs, defStyleAttr) {
|
||||||
|
View.inflate(context, R.layout.message_audio_view, this)
|
||||||
|
container = findViewById(R.id.audio_widget_container)
|
||||||
|
controlToggle = findViewById(R.id.control_toggle)
|
||||||
|
playButton = findViewById(R.id.play)
|
||||||
|
pauseButton = findViewById(R.id.pause)
|
||||||
|
downloadButton = findViewById(R.id.download)
|
||||||
|
downloadProgress = findViewById(R.id.download_progress)
|
||||||
|
seekBar = findViewById(R.id.seek)
|
||||||
|
totalDuration = findViewById(R.id.total_duration)
|
||||||
|
|
||||||
|
playButton.setOnClickListener {
|
||||||
|
try {
|
||||||
|
Log.d(TAG, "playbutton onClick")
|
||||||
|
if (audioSlidePlayer != null) {
|
||||||
|
togglePlayToPause()
|
||||||
|
|
||||||
|
// Restart the playback if progress bar is nearly at the end.
|
||||||
|
val progress = if (seekBar.progress < 0.99f) seekBar.progress.toDouble() else 0.0
|
||||||
|
|
||||||
|
audioSlidePlayer!!.play(progress)
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Log.w(TAG, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pauseButton.setOnClickListener {
|
||||||
|
Log.d(TAG, "pausebutton onClick")
|
||||||
|
if (audioSlidePlayer != null) {
|
||||||
|
togglePauseToPlay()
|
||||||
|
audioSlidePlayer!!.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seekBar.isEnabled = false
|
||||||
|
seekBar.progressChangeListener = object : WaveformSeekBar.ProgressChangeListener {
|
||||||
|
override fun onProgressChanged(waveformSeekBar: WaveformSeekBar, progress: Float, fromUser: Boolean) {
|
||||||
|
if (fromUser && audioSlidePlayer != null) {
|
||||||
|
synchronized(audioSlidePlayer!!) {
|
||||||
|
audioSlidePlayer!!.seekTo(progress.toDouble())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
playButton.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.play_icon))
|
||||||
|
pauseButton.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.pause_icon))
|
||||||
|
playButton.background = ContextCompat.getDrawable(context, R.drawable.ic_circle_fill_white_48dp)
|
||||||
|
pauseButton.background = ContextCompat.getDrawable(context, R.drawable.ic_circle_fill_white_48dp)
|
||||||
|
|
||||||
|
if (attrs != null) {
|
||||||
|
val typedArray = context.theme.obtainStyledAttributes(attrs, R.styleable.MessageAudioView, 0, 0)
|
||||||
|
setTint(typedArray.getColor(R.styleable.MessageAudioView_foregroundTintColor, Color.WHITE),
|
||||||
|
typedArray.getColor(R.styleable.MessageAudioView_waveformFillColor, Color.WHITE),
|
||||||
|
typedArray.getColor(R.styleable.MessageAudioView_waveformBackgroundColor, Color.WHITE))
|
||||||
|
container.setBackgroundColor(typedArray.getColor(R.styleable.MessageAudioView_widgetBackground, Color.TRANSPARENT))
|
||||||
|
typedArray.recycle()
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingAnimation = SeekBarLoadingAnimation(this, seekBar)
|
||||||
|
loadingAnimation.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAttachedToWindow() {
|
||||||
|
super.onAttachedToWindow()
|
||||||
|
if (!EventBus.getDefault().isRegistered(this)) EventBus.getDefault().register(this)
|
||||||
|
|
||||||
|
asyncCoroutineScope = CoroutineScope(Job() + Dispatchers.IO)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDetachedFromWindow() {
|
||||||
|
super.onDetachedFromWindow()
|
||||||
|
EventBus.getDefault().unregister(this)
|
||||||
|
|
||||||
|
// Cancel all the background operations.
|
||||||
|
asyncCoroutineScope!!.cancel()
|
||||||
|
asyncCoroutineScope = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAudio(audio: AudioSlide, showControls: Boolean) {
|
||||||
|
when {
|
||||||
|
showControls && audio.isPendingDownload -> {
|
||||||
|
controlToggle.displayQuick(downloadButton)
|
||||||
|
seekBar.isEnabled = false
|
||||||
|
downloadButton.setOnClickListener { v -> downloadListener?.onClick(v, audio) }
|
||||||
|
if (downloadProgress.isIndeterminate) {
|
||||||
|
downloadProgress.isIndeterminate = false
|
||||||
|
downloadProgress.progress = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(showControls && audio.transferState == AttachmentDatabase.TRANSFER_PROGRESS_STARTED) -> {
|
||||||
|
controlToggle.displayQuick(downloadProgress)
|
||||||
|
seekBar.isEnabled = false
|
||||||
|
downloadProgress.isIndeterminate = true
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
controlToggle.displayQuick(playButton)
|
||||||
|
seekBar.isEnabled = true
|
||||||
|
if (downloadProgress.isIndeterminate) {
|
||||||
|
downloadProgress.isIndeterminate = false
|
||||||
|
downloadProgress.progress = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post to make sure it executes only when the view is attached to a window.
|
||||||
|
post(::updateFromAttachmentAudioExtras)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
audioSlidePlayer = AudioSlidePlayer.createFor(context, audio, this)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cleanup() {
|
||||||
|
if (audioSlidePlayer != null && pauseButton.visibility == View.VISIBLE) {
|
||||||
|
audioSlidePlayer!!.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDownloadClickListener(listener: SlideClickListener?) {
|
||||||
|
downloadListener = listener
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTint(@ColorInt foregroundTint: Int, @ColorInt waveformFill: Int, @ColorInt waveformBackground: Int) {
|
||||||
|
playButton.backgroundTintList = ColorStateList.valueOf(resources.getColorWithID(R.color.white, context.theme))
|
||||||
|
playButton.imageTintList = ColorStateList.valueOf(resources.getColorWithID(R.color.black, context.theme))
|
||||||
|
pauseButton.backgroundTintList = ColorStateList.valueOf(resources.getColorWithID(R.color.white, context.theme))
|
||||||
|
pauseButton.imageTintList = ColorStateList.valueOf(resources.getColorWithID(R.color.black, context.theme))
|
||||||
|
|
||||||
|
downloadButton.setColorFilter(foregroundTint, PorterDuff.Mode.SRC_IN)
|
||||||
|
|
||||||
|
downloadProgress.backgroundTintList = ColorStateList.valueOf(resources.getColorWithID(R.color.white, context.theme))
|
||||||
|
downloadProgress.progressTintList = ColorStateList.valueOf(resources.getColorWithID(R.color.black, context.theme))
|
||||||
|
downloadProgress.indeterminateTintList = ColorStateList.valueOf(resources.getColorWithID(R.color.black, context.theme))
|
||||||
|
|
||||||
|
totalDuration.setTextColor(foregroundTint)
|
||||||
|
|
||||||
|
seekBar.barProgressColor = waveformFill
|
||||||
|
seekBar.barBackgroundColor = waveformBackground
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPlayerStart(player: AudioSlidePlayer) {
|
||||||
|
if (pauseButton.visibility != View.VISIBLE) {
|
||||||
|
togglePlayToPause()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPlayerStop(player: AudioSlidePlayer) {
|
||||||
|
if (playButton.visibility != View.VISIBLE) {
|
||||||
|
togglePauseToPlay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPlayerProgress(player: AudioSlidePlayer, progress: Double, millis: Long) {
|
||||||
|
seekBar.progress = progress.toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setFocusable(focusable: Boolean) {
|
||||||
|
super.setFocusable(focusable)
|
||||||
|
playButton.isFocusable = focusable
|
||||||
|
pauseButton.isFocusable = focusable
|
||||||
|
seekBar.isFocusable = focusable
|
||||||
|
seekBar.isFocusableInTouchMode = focusable
|
||||||
|
downloadButton.isFocusable = focusable
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setClickable(clickable: Boolean) {
|
||||||
|
super.setClickable(clickable)
|
||||||
|
playButton.isClickable = clickable
|
||||||
|
pauseButton.isClickable = clickable
|
||||||
|
seekBar.isClickable = clickable
|
||||||
|
seekBar.setOnTouchListener(if (clickable) null else
|
||||||
|
OnTouchListener { _, _ -> return@OnTouchListener true }) // Suppress touch events.
|
||||||
|
downloadButton.isClickable = clickable
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setEnabled(enabled: Boolean) {
|
||||||
|
super.setEnabled(enabled)
|
||||||
|
playButton.isEnabled = enabled
|
||||||
|
pauseButton.isEnabled = enabled
|
||||||
|
downloadButton.isEnabled = enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun togglePlayToPause() {
|
||||||
|
controlToggle.displayQuick(pauseButton)
|
||||||
|
val playToPauseDrawable = ContextCompat.getDrawable(context, R.drawable.play_to_pause_animation) as AnimatedVectorDrawable
|
||||||
|
pauseButton.setImageDrawable(playToPauseDrawable)
|
||||||
|
playToPauseDrawable.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun togglePauseToPlay() {
|
||||||
|
controlToggle.displayQuick(playButton)
|
||||||
|
val pauseToPlayDrawable = ContextCompat.getDrawable(context, R.drawable.pause_to_play_animation) as AnimatedVectorDrawable
|
||||||
|
playButton.setImageDrawable(pauseToPlayDrawable)
|
||||||
|
pauseToPlayDrawable.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun obtainDatabaseAttachment(): DatabaseAttachment? {
|
||||||
|
audioSlidePlayer ?: return null
|
||||||
|
val attachment = audioSlidePlayer!!.audioSlide.asAttachment()
|
||||||
|
return if (attachment is DatabaseAttachment) attachment else null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateFromAttachmentAudioExtras() {
|
||||||
|
val attachment = obtainDatabaseAttachment() ?: return
|
||||||
|
|
||||||
|
val audioExtras = DatabaseFactory.getAttachmentDatabase(context)
|
||||||
|
.getAttachmentAudioExtras(attachment.attachmentId)
|
||||||
|
|
||||||
|
// Schedule a job request if no audio extras were generated yet.
|
||||||
|
if (audioExtras == null) {
|
||||||
|
ApplicationContext.getInstance(context).jobManager
|
||||||
|
.add(PrepareAttachmentAudioExtrasJob(attachment.attachmentId))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingAnimation.stop()
|
||||||
|
seekBar.sampleData = audioExtras.visualSamples
|
||||||
|
|
||||||
|
if (audioExtras.durationMs > 0) {
|
||||||
|
totalDuration.visibility = View.VISIBLE
|
||||||
|
totalDuration.text = String.format("%02d:%02d",
|
||||||
|
TimeUnit.MILLISECONDS.toMinutes(audioExtras.durationMs),
|
||||||
|
TimeUnit.MILLISECONDS.toSeconds(audioExtras.durationMs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Subscribe(sticky = true, threadMode = ThreadMode.MAIN)
|
||||||
|
fun onEvent(event: PartProgressEvent) {
|
||||||
|
if (audioSlidePlayer != null && event.attachment == audioSlidePlayer!!.audioSlide.asAttachment()) {
|
||||||
|
val progress = ((event.progress.toFloat() / event.total) * 100f).toInt()
|
||||||
|
downloadProgress.progress = progress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Subscribe(threadMode = ThreadMode.MAIN)
|
||||||
|
fun onEvent(event: PrepareAttachmentAudioExtrasJob.AudioExtrasUpdatedEvent) {
|
||||||
|
if (event.attachmentId == obtainDatabaseAttachment()?.attachmentId) {
|
||||||
|
updateFromAttachmentAudioExtras()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class SeekBarLoadingAnimation(
|
||||||
|
private val hostView: View,
|
||||||
|
private val seekBar: WaveformSeekBar): Runnable {
|
||||||
|
|
||||||
|
private var active = false
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val UPDATE_PERIOD = 250L // In milliseconds.
|
||||||
|
private val random = Random()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
stop()
|
||||||
|
active = true
|
||||||
|
hostView.postDelayed(this, UPDATE_PERIOD)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
active = false
|
||||||
|
hostView.removeCallbacks(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun run() {
|
||||||
|
if (!active) return
|
||||||
|
|
||||||
|
// Generate a random samples with values up to the 50% of the maximum value.
|
||||||
|
seekBar.sampleData = ByteArray(PrepareAttachmentAudioExtrasJob.VISUAL_RMS_FRAMES)
|
||||||
|
{ (random.nextInt(127) - 64).toByte() }
|
||||||
|
hostView.postDelayed(this, UPDATE_PERIOD)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -85,7 +85,7 @@ class PathStatusView : View {
|
|||||||
private fun handlePathsBuiltEvent() { update() }
|
private fun handlePathsBuiltEvent() { update() }
|
||||||
|
|
||||||
private fun update() {
|
private fun update() {
|
||||||
if (OnionRequestAPI.paths.count() >= OnionRequestAPI.pathCount) {
|
if (OnionRequestAPI.paths.isNotEmpty()) {
|
||||||
setBackgroundResource(R.drawable.accent_dot)
|
setBackgroundResource(R.drawable.accent_dot)
|
||||||
mainColor = resources.getColorWithID(R.color.accent, context.theme)
|
mainColor = resources.getColorWithID(R.color.accent, context.theme)
|
||||||
sessionShadowColor = resources.getColorWithID(R.color.accent, context.theme)
|
sessionShadowColor = resources.getColorWithID(R.color.accent, context.theme)
|
||||||
|
@ -68,32 +68,30 @@ class ProfilePictureView : RelativeLayout {
|
|||||||
return result ?: publicKey
|
return result ?: publicKey
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (recipient.isGroupRecipient) {
|
fun isOpenGroupWithProfilePicture(recipient: Recipient): Boolean {
|
||||||
if ("Session Public Chat" == recipient.name) {
|
return recipient.isOpenGroupRecipient && recipient.groupAvatarId != null
|
||||||
publicKey = ""
|
}
|
||||||
displayName = ""
|
if (recipient.isGroupRecipient && !isOpenGroupWithProfilePicture(recipient)) {
|
||||||
additionalPublicKey = null
|
val users = MentionsManager.shared.userPublicKeyCache[threadID]?.toMutableList() ?: mutableListOf()
|
||||||
isRSSFeed = true
|
users.remove(TextSecurePreferences.getLocalNumber(context))
|
||||||
} else {
|
val masterPublicKey = TextSecurePreferences.getMasterHexEncodedPublicKey(context)
|
||||||
val users = MentionsManager.shared.userPublicKeyCache[threadID]?.toMutableList() ?: mutableListOf()
|
if (masterPublicKey != null) {
|
||||||
users.remove(TextSecurePreferences.getLocalNumber(context))
|
users.remove(masterPublicKey)
|
||||||
val masterPublicKey = TextSecurePreferences.getMasterHexEncodedPublicKey(context)
|
|
||||||
if (masterPublicKey != null) {
|
|
||||||
users.remove(masterPublicKey)
|
|
||||||
}
|
|
||||||
val randomUsers = users.sorted().toMutableList() // Sort to provide a level of stability
|
|
||||||
if (users.count() == 1) {
|
|
||||||
val userPublicKey = TextSecurePreferences.getLocalNumber(context)
|
|
||||||
randomUsers.add(0, userPublicKey) // Ensure the current user is at the back visually
|
|
||||||
}
|
|
||||||
val pk = randomUsers.getOrNull(0) ?: ""
|
|
||||||
publicKey = pk
|
|
||||||
displayName = getUserDisplayName(pk)
|
|
||||||
val apk = randomUsers.getOrNull(1) ?: ""
|
|
||||||
additionalPublicKey = apk
|
|
||||||
additionalDisplayName = getUserDisplayName(apk)
|
|
||||||
isRSSFeed = recipient.name == "Loki News" || recipient.name == "Session Updates"
|
|
||||||
}
|
}
|
||||||
|
val randomUsers = users.sorted().toMutableList() // Sort to provide a level of stability
|
||||||
|
if (users.count() == 1) {
|
||||||
|
val userPublicKey = TextSecurePreferences.getLocalNumber(context)
|
||||||
|
randomUsers.add(0, userPublicKey) // Ensure the current user is at the back visually
|
||||||
|
}
|
||||||
|
val pk = randomUsers.getOrNull(0) ?: ""
|
||||||
|
publicKey = pk
|
||||||
|
displayName = getUserDisplayName(pk)
|
||||||
|
val apk = randomUsers.getOrNull(1) ?: ""
|
||||||
|
additionalPublicKey = apk
|
||||||
|
additionalDisplayName = getUserDisplayName(apk)
|
||||||
|
isRSSFeed = recipient.name == "Loki News" ||
|
||||||
|
recipient.name == "Session Updates" ||
|
||||||
|
recipient.name == "Session Public Chat"
|
||||||
} else {
|
} else {
|
||||||
publicKey = recipient.address.toString()
|
publicKey = recipient.address.toString()
|
||||||
displayName = getUserDisplayName(publicKey)
|
displayName = getUserDisplayName(publicKey)
|
||||||
|
315
src/org/thoughtcrime/securesms/loki/views/WaveformSeekBar.kt
Normal file
315
src/org/thoughtcrime/securesms/loki/views/WaveformSeekBar.kt
Normal file
@ -0,0 +1,315 @@
|
|||||||
|
package org.thoughtcrime.securesms.loki.views
|
||||||
|
|
||||||
|
import android.animation.ValueAnimator
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.RectF
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.util.TypedValue
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewConfiguration
|
||||||
|
import android.view.animation.DecelerateInterpolator
|
||||||
|
import androidx.core.math.MathUtils
|
||||||
|
import network.loki.messenger.R
|
||||||
|
import org.thoughtcrime.securesms.loki.utilities.audio.byteToNormalizedFloat
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
class WaveformSeekBar : View {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@JvmStatic
|
||||||
|
fun dp(context: Context, dp: Float): Float {
|
||||||
|
return TypedValue.applyDimension(
|
||||||
|
TypedValue.COMPLEX_UNIT_DIP,
|
||||||
|
dp,
|
||||||
|
context.resources.displayMetrics
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val sampleDataHolder = SampleDataHolder(::invalidate)
|
||||||
|
/** An array of signed byte values representing the audio signal. */
|
||||||
|
var sampleData: ByteArray?
|
||||||
|
get() {
|
||||||
|
return sampleDataHolder.getSamples()
|
||||||
|
}
|
||||||
|
set(value) {
|
||||||
|
sampleDataHolder.setSamples(value)
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Indicates whether the user is currently interacting with the view and performing a seeking gesture. */
|
||||||
|
private var userSeeking = false
|
||||||
|
private var _progress: Float = 0f
|
||||||
|
/** In [0..1] range. */
|
||||||
|
var progress: Float
|
||||||
|
set(value) {
|
||||||
|
// Do not let to modify the progress value from the outside
|
||||||
|
// when the user is currently interacting with the view.
|
||||||
|
if (userSeeking) return
|
||||||
|
|
||||||
|
_progress = value
|
||||||
|
invalidate()
|
||||||
|
progressChangeListener?.onProgressChanged(this, _progress, false)
|
||||||
|
}
|
||||||
|
get() {
|
||||||
|
return _progress
|
||||||
|
}
|
||||||
|
|
||||||
|
var barBackgroundColor: Int = Color.LTGRAY
|
||||||
|
set(value) {
|
||||||
|
field = value
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
var barProgressColor: Int = Color.WHITE
|
||||||
|
set(value) {
|
||||||
|
field = value
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
var barGap: Float = dp(context, 2f)
|
||||||
|
set(value) {
|
||||||
|
field = value
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
var barWidth: Float = dp(context, 5f)
|
||||||
|
set(value) {
|
||||||
|
field = value
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
var barMinHeight: Float = barWidth
|
||||||
|
set(value) {
|
||||||
|
field = value
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
var barCornerRadius: Float = dp(context, 2.5f)
|
||||||
|
set(value) {
|
||||||
|
field = value
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
var barGravity: WaveGravity = WaveGravity.CENTER
|
||||||
|
set(value) {
|
||||||
|
field = value
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
var progressChangeListener: ProgressChangeListener? = null
|
||||||
|
|
||||||
|
private val barPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
private val barRect = RectF()
|
||||||
|
|
||||||
|
private var canvasWidth = 0
|
||||||
|
private var canvasHeight = 0
|
||||||
|
|
||||||
|
private var touchDownX = 0f
|
||||||
|
private var touchDownProgress: Float = 0f
|
||||||
|
private var scaledTouchSlop = ViewConfiguration.get(context).scaledTouchSlop
|
||||||
|
|
||||||
|
constructor(context: Context) : this(context, null)
|
||||||
|
|
||||||
|
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
|
||||||
|
|
||||||
|
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int)
|
||||||
|
: super(context, attrs, defStyleAttr) {
|
||||||
|
|
||||||
|
val typedAttrs = context.obtainStyledAttributes(attrs, R.styleable.WaveformSeekBar)
|
||||||
|
barWidth = typedAttrs.getDimension(R.styleable.WaveformSeekBar_bar_width, barWidth)
|
||||||
|
barGap = typedAttrs.getDimension(R.styleable.WaveformSeekBar_bar_gap, barGap)
|
||||||
|
barCornerRadius = typedAttrs.getDimension(
|
||||||
|
R.styleable.WaveformSeekBar_bar_corner_radius,
|
||||||
|
barCornerRadius)
|
||||||
|
barMinHeight =
|
||||||
|
typedAttrs.getDimension(R.styleable.WaveformSeekBar_bar_min_height, barMinHeight)
|
||||||
|
barBackgroundColor = typedAttrs.getColor(
|
||||||
|
R.styleable.WaveformSeekBar_bar_background_color,
|
||||||
|
barBackgroundColor)
|
||||||
|
barProgressColor =
|
||||||
|
typedAttrs.getColor(R.styleable.WaveformSeekBar_bar_progress_color, barProgressColor)
|
||||||
|
progress = typedAttrs.getFloat(R.styleable.WaveformSeekBar_progress, progress)
|
||||||
|
barGravity = WaveGravity.fromString(
|
||||||
|
typedAttrs.getString(R.styleable.WaveformSeekBar_bar_gravity))
|
||||||
|
|
||||||
|
typedAttrs.recycle()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||||
|
super.onSizeChanged(w, h, oldw, oldh)
|
||||||
|
canvasWidth = w
|
||||||
|
canvasHeight = h
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDraw(canvas: Canvas) {
|
||||||
|
super.onDraw(canvas)
|
||||||
|
|
||||||
|
val totalWidth = getAvailableWidth()
|
||||||
|
val barAmount = (totalWidth / (barWidth + barGap)).toInt()
|
||||||
|
|
||||||
|
var lastBarRight = paddingLeft.toFloat()
|
||||||
|
|
||||||
|
(0 until barAmount).forEach { barIdx ->
|
||||||
|
// Convert a signed byte to a [0..1] float.
|
||||||
|
val barValue = byteToNormalizedFloat(sampleDataHolder.computeBarValue(barIdx, barAmount))
|
||||||
|
|
||||||
|
val barHeight = max(barMinHeight, getAvailableHeight() * barValue)
|
||||||
|
|
||||||
|
val top: Float = when (barGravity) {
|
||||||
|
WaveGravity.TOP -> paddingTop.toFloat()
|
||||||
|
WaveGravity.CENTER -> paddingTop + getAvailableHeight() * 0.5f - barHeight * 0.5f
|
||||||
|
WaveGravity.BOTTOM -> canvasHeight - paddingBottom - barHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
barRect.set(lastBarRight, top, lastBarRight + barWidth, top + barHeight)
|
||||||
|
|
||||||
|
barPaint.color = if (barRect.right <= totalWidth * progress)
|
||||||
|
barProgressColor else barBackgroundColor
|
||||||
|
|
||||||
|
canvas.drawRoundRect(barRect, barCornerRadius, barCornerRadius, barPaint)
|
||||||
|
|
||||||
|
lastBarRight = barRect.right + barGap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||||
|
if (!isEnabled) return false
|
||||||
|
|
||||||
|
when (event.action) {
|
||||||
|
MotionEvent.ACTION_DOWN -> {
|
||||||
|
userSeeking = true
|
||||||
|
touchDownX = event.x
|
||||||
|
touchDownProgress = progress
|
||||||
|
updateProgress(event, false)
|
||||||
|
}
|
||||||
|
MotionEvent.ACTION_MOVE -> {
|
||||||
|
// Prevent any parent scrolling if the user scrolled more
|
||||||
|
// than scaledTouchSlop on horizontal axis.
|
||||||
|
if (abs(event.x - touchDownX) > scaledTouchSlop) {
|
||||||
|
parent.requestDisallowInterceptTouchEvent(true)
|
||||||
|
}
|
||||||
|
updateProgress(event, false)
|
||||||
|
}
|
||||||
|
MotionEvent.ACTION_UP -> {
|
||||||
|
userSeeking = false
|
||||||
|
updateProgress(event, true)
|
||||||
|
performClick()
|
||||||
|
}
|
||||||
|
MotionEvent.ACTION_CANCEL -> {
|
||||||
|
updateProgress(touchDownProgress, false)
|
||||||
|
userSeeking = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateProgress(event: MotionEvent, notify: Boolean) {
|
||||||
|
updateProgress(event.x / getAvailableWidth(), notify)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateProgress(progress: Float, notify: Boolean) {
|
||||||
|
_progress = MathUtils.clamp(progress, 0f, 1f)
|
||||||
|
invalidate()
|
||||||
|
|
||||||
|
if (notify) {
|
||||||
|
progressChangeListener?.onProgressChanged(this, _progress, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun performClick(): Boolean {
|
||||||
|
super.performClick()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getAvailableWidth() = canvasWidth - paddingLeft - paddingRight
|
||||||
|
private fun getAvailableHeight() = canvasHeight - paddingTop - paddingBottom
|
||||||
|
|
||||||
|
private class SampleDataHolder(private val invalidateDelegate: () -> Any) {
|
||||||
|
|
||||||
|
private var sampleDataFrom: ByteArray? = null
|
||||||
|
private var sampleDataTo: ByteArray? = null
|
||||||
|
private var progress = 1f // Mix between from and to values.
|
||||||
|
|
||||||
|
private var animation: ValueAnimator? = null
|
||||||
|
|
||||||
|
fun computeBarValue(barIdx: Int, barAmount: Int): Byte {
|
||||||
|
/** @return The array's value at the interpolated index. */
|
||||||
|
fun getSampleValue(sampleData: ByteArray?): Byte {
|
||||||
|
if (sampleData == null || sampleData.isEmpty())
|
||||||
|
return Byte.MIN_VALUE
|
||||||
|
else {
|
||||||
|
val sampleIdx = (barIdx * (sampleData.size / barAmount.toFloat())).toInt()
|
||||||
|
return sampleData[sampleIdx]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progress == 1f) {
|
||||||
|
return getSampleValue(sampleDataTo)
|
||||||
|
}
|
||||||
|
|
||||||
|
val fromValue = getSampleValue(sampleDataFrom)
|
||||||
|
val toValue = getSampleValue(sampleDataTo)
|
||||||
|
val rawResultValue = fromValue * (1f - progress) + toValue * progress
|
||||||
|
return rawResultValue.roundToInt().toByte()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSamples(sampleData: ByteArray?) {
|
||||||
|
/** @return a mix between [sampleDataFrom] and [sampleDataTo] arrays according to the current [progress] value. */
|
||||||
|
fun computeNewDataFromArray(): ByteArray? {
|
||||||
|
if (sampleDataTo == null) return null
|
||||||
|
if (sampleDataFrom == null) return sampleDataTo
|
||||||
|
|
||||||
|
val sampleSize = min(sampleDataFrom!!.size, sampleDataTo!!.size)
|
||||||
|
return ByteArray(sampleSize) { i -> computeBarValue(i, sampleSize) }
|
||||||
|
}
|
||||||
|
|
||||||
|
sampleDataFrom = computeNewDataFromArray()
|
||||||
|
sampleDataTo = sampleData
|
||||||
|
progress = 0f
|
||||||
|
|
||||||
|
animation?.cancel()
|
||||||
|
animation = ValueAnimator.ofFloat(0f, 1f).apply {
|
||||||
|
addUpdateListener { animation ->
|
||||||
|
progress = animation.animatedValue as Float
|
||||||
|
invalidateDelegate()
|
||||||
|
}
|
||||||
|
interpolator = DecelerateInterpolator(3f)
|
||||||
|
duration = 500
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSamples(): ByteArray? {
|
||||||
|
return sampleDataTo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class WaveGravity {
|
||||||
|
TOP,
|
||||||
|
CENTER,
|
||||||
|
BOTTOM,
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@JvmStatic
|
||||||
|
fun fromString(gravity: String?): WaveGravity = when (gravity) {
|
||||||
|
"1" -> TOP
|
||||||
|
"2" -> CENTER
|
||||||
|
else -> BOTTOM
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProgressChangeListener {
|
||||||
|
fun onProgressChanged(waveformSeekBar: WaveformSeekBar, progress: Float, fromUser: Boolean)
|
||||||
|
}
|
||||||
|
}
|
@ -41,7 +41,7 @@ import androidx.annotation.Nullable;
|
|||||||
import org.thoughtcrime.securesms.MediaPreviewActivity;
|
import org.thoughtcrime.securesms.MediaPreviewActivity;
|
||||||
import org.thoughtcrime.securesms.TransportOption;
|
import org.thoughtcrime.securesms.TransportOption;
|
||||||
import org.thoughtcrime.securesms.attachments.Attachment;
|
import org.thoughtcrime.securesms.attachments.Attachment;
|
||||||
import org.thoughtcrime.securesms.components.AudioView;
|
import org.thoughtcrime.securesms.loki.views.MessageAudioView;
|
||||||
import org.thoughtcrime.securesms.components.DocumentView;
|
import org.thoughtcrime.securesms.components.DocumentView;
|
||||||
import org.thoughtcrime.securesms.components.RemovableEditableMediaView;
|
import org.thoughtcrime.securesms.components.RemovableEditableMediaView;
|
||||||
import org.thoughtcrime.securesms.components.ThumbnailView;
|
import org.thoughtcrime.securesms.components.ThumbnailView;
|
||||||
@ -91,7 +91,7 @@ public class AttachmentManager {
|
|||||||
|
|
||||||
private RemovableEditableMediaView removableMediaView;
|
private RemovableEditableMediaView removableMediaView;
|
||||||
private ThumbnailView thumbnail;
|
private ThumbnailView thumbnail;
|
||||||
private AudioView audioView;
|
private MessageAudioView audioView;
|
||||||
private DocumentView documentView;
|
private DocumentView documentView;
|
||||||
private SignalMapView mapView;
|
private SignalMapView mapView;
|
||||||
|
|
||||||
|
@ -131,7 +131,7 @@ public abstract class Slide {
|
|||||||
|
|
||||||
public @NonNull String getContentDescription() { return ""; }
|
public @NonNull String getContentDescription() { return ""; }
|
||||||
|
|
||||||
public Attachment asAttachment() {
|
public @NonNull Attachment asAttachment() {
|
||||||
return attachment;
|
return attachment;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -84,13 +84,17 @@ public class SingleRecipientNotificationBuilder extends AbstractNotificationBuil
|
|||||||
ContactPhoto contactPhoto = recipient.getContactPhoto();
|
ContactPhoto contactPhoto = recipient.getContactPhoto();
|
||||||
if (contactPhoto != null) {
|
if (contactPhoto != null) {
|
||||||
try {
|
try {
|
||||||
setLargeIcon(GlideApp.with(context.getApplicationContext())
|
// AC: For some reason, if not use ".asBitmap()" method, the returned BitmapDrawable
|
||||||
.load(contactPhoto)
|
// wraps a recycled bitmap and leads to a crash.
|
||||||
.diskCacheStrategy(DiskCacheStrategy.ALL)
|
Bitmap iconBitmap = GlideApp.with(context.getApplicationContext())
|
||||||
.circleCrop()
|
.asBitmap()
|
||||||
.submit(context.getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_width),
|
.load(contactPhoto)
|
||||||
context.getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_height))
|
.diskCacheStrategy(DiskCacheStrategy.ALL)
|
||||||
.get());
|
.circleCrop()
|
||||||
|
.submit(context.getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_width),
|
||||||
|
context.getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_height))
|
||||||
|
.get();
|
||||||
|
setLargeIcon(iconBitmap);
|
||||||
} catch (InterruptedException | ExecutionException e) {
|
} catch (InterruptedException | ExecutionException e) {
|
||||||
Log.w(TAG, e);
|
Log.w(TAG, e);
|
||||||
setLargeIcon(getPlaceholderDrawable(context, recipient));
|
setLargeIcon(getPlaceholderDrawable(context, recipient));
|
||||||
|
@ -419,6 +419,10 @@ public class Recipient implements RecipientModifiedListener {
|
|||||||
return address.isGroup();
|
return address.isGroup();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean isOpenGroupRecipient() {
|
||||||
|
return address.isOpenGroup();
|
||||||
|
}
|
||||||
|
|
||||||
public boolean isMmsGroupRecipient() {
|
public boolean isMmsGroupRecipient() {
|
||||||
return address.isMmsGroup();
|
return address.isMmsGroup();
|
||||||
}
|
}
|
||||||
@ -505,6 +509,11 @@ public class Recipient implements RecipientModifiedListener {
|
|||||||
if (notify) notifyListeners();
|
if (notify) notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public synchronized Long getGroupAvatarId() {
|
||||||
|
return groupAvatarId;
|
||||||
|
}
|
||||||
|
|
||||||
public synchronized @Nullable Uri getMessageRingtone() {
|
public synchronized @Nullable Uri getMessageRingtone() {
|
||||||
if (messageRingtone != null && messageRingtone.getScheme() != null && messageRingtone.getScheme().startsWith("file")) {
|
if (messageRingtone != null && messageRingtone.getScheme() != null && messageRingtone.getScheme().startsWith("file")) {
|
||||||
return null;
|
return null;
|
||||||
|
32
src/org/thoughtcrime/securesms/util/ParcelableUtil.kt
Normal file
32
src/org/thoughtcrime/securesms/util/ParcelableUtil.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user